图标虽小,里面的门道可一点都不少。甚至可以说,图标的演化是 Web 技术演化的一个缩影。本文将带你回顾一下图标简史,了解一下图标技术的来龙去脉。
古代:一个图标一张图
史前时代的图标,正如我们的直觉一样,就是一张图片。那时候的网络很慢,一分钟只够下载一个页面,因此内容为王,美观是次要的,“没什么用”的图标还没有被人们视为页面上的必备元素。图标个数少、使用频率低,自然就没人在上面花心思了。
近代:CSS Sprites(雪碧图)
随着网上内容迅速丰富,内容的比拼已经没有更多花样可玩了,于是网站的竞争转向了“用户体验”领域。当然,后来内容又重新回到了舞台中央,不过这已经是后话了。
在体验方面追求差异化的方式很多,而在宽带网络还不够普及的时代,最直观的方面就是加载速度。然而“一个图标一张图”的方式在加载速度方面受到了严重限制。限制主要来自两个方面:建立连接的时间,和浏览器的并发下载数量限制。前者来自 HTTP 协议,而后者则来自浏览器的实现。
HTTP 方面的限制很容易理解,代理、DNS、握手、发送请求、TTFB 的时间对于像图标这样的小文件来说很可能远超下载内容的时间。
浏览器的并发限制其实在技术上来说是很有必要的。如果不限制并发下载数,一方面浏览器就会开很多个线程,用户的机器受不了;另一方面服务器也会收到大量的并发请求,服务器也受不了,很容易压垮一些技术不过硬的网站。
浏览器限制并发下载数,就会导致超出并发限制的请求被迫进行排队,对于图标、图片、css、js 等小文件很多的页面来说,即使网速已经较快,这种排队也可能会持续很久。
显然,优化的方向就是减少并发下载的需求。因此,优化的方案也就显而易见了:把各种小图标拼合成一个大图,然后想办法让浏览器把它重新切成多个就可以了。恰好,浏览器有一个特性叫background-position
,也就是说假如我们把这张大图设置为当前元素(宽w
、高h
)的背景,并且指定了 background-position
为 (x, y)
,那么当前元素的背景就是从大图上 (x, y, x w, y h)
截取出来的那个区域。这样一来,就把 N 个并发下载合并成了一张大图和一个 css 文件。
这种方案如果做手动拼合是非常繁琐的,因此有人开发了工具来整合到前端工具链中,并且 UX 的一些工具也逐渐提供了直接导出雪碧图的功能。
近代:Data URL
除了拼合成雪碧图之外,还有另一种技术也可以减少并发下载请求,那就是 Data URL。
简单来说,Data URL 就是这样的形式:data:[<mediatype>][;base64],<data>,
比如 data:image/svg xml;utf8,<svg ... > ... </svg>
, 也可以对 data
部分进行 base64 编码, 比如data:image/svg xml;base64,PHN2ZyB4bWxucz0iaHR0cDo
。
对于浏览器来说,Data URL 和普通的 http URL 没有什么区别 —— 除了不用额外下载。因此,凡是能用 http URL 的地方都可以换成 Data URL,比如 html 中的 <img src="...">
,css 中的 background-image: url(...)
。这样一来,就把图标的下载合并到了 html/css 的下载过程中。
但是,这种方式也有缺点,那就是拖慢了整体渲染速度。
通常来讲,浏览器的下载优先级是 html > css > 图片等资源的,因此我们经常看到一个网站展现出来之后,里面的图片还只显示了一半,过一会儿才会完全显示。如果我们把大量图标塞到 css 甚至 html 中,就会增大它们的体积,导致首屏展现变慢。
所以,是否使用 Data URL 技术需要仔细权衡,根据性能测量数据进行优化。
现代:字体图标
随着视网膜屏幕的登场,图标面临着新的严峻挑战,那就是分辨率。
通常来说,图标文件的分辨率和屏幕的逻辑分辨率是一样的,但是在视网膜屏下,这个论断不再成立。如果视网膜屏的设备像素比(devicePixelRatio
,简称 dpr)是 3,那么图标就需要三个像素才能在视网膜屏下绘制出一个完美的逻辑像素,否则就会有粗糙感。
即使不考虑下载大小的问题,也需要对原有工具链进行改造才行。那不如干脆试试另一种方案。
近代的另一项发明派上了用场,那就是“自定义字体”。
这本来是为了解决让浏览器显示更好看的文字而创造的技术,比如要想用一种用户机器上没有的字体显示艺术字,我们只需要提供一个字体文件,这些字体文件包含我们要用的那些文字的字体轮廓数据就可以了。这些轮廓数据是矢量数据,用来表示每个字的“画法”:从 0,0
开始,以 50%,10%
为控制点,画一条贝塞尔曲线到 100%,30%
。显然,这种数据是不会受到屏幕分辨率影响的,就像我们日常看到的文字一样,无论把它放到多大,它都是平滑而且不失真的。事实上,这正是一切矢量绘图技术共同的优点。请记住它,因为后面我们还会用到另一种矢量绘图技术。
既然我们可以通过控制显示数据,把字母 A 显示为手写体的 A,那么我们是不是也可以把它显示成一个看起来和 A 完全不一样的图标呢?比如……一座房子?当然可以,事实上,这正是字体图标的基本原理。
除了支持平滑缩放的优势以外,字体图标还有另一个优势,那就是它本身就是文字。它会受到字号、前景色、行高等参数的控制,和普通文字没有任何区别。而图标,在实际应用中经常会和普通文字一起混排,这些特点正是我们想要的。
不过,字体图标也有一些缺点。
首要的缺点是单色。由于字体中只有矢量数据,没有颜色数据,因此,字体图标必然是单色的。这在一些场景下是不够用的。
其次是工具链复杂。虽然有一些工具可以帮你把一组 svg 文件拼合成一个字体文件,但是它们对 svg 的格式有严格的要求,不是任何一个 svg 都可以用的。你很难向 UX 解释什么样的图能用、什么样的图不能用。其次,即使是可用的 svg,你也很难告诉工具每个图标的字体基线是哪个(通俗来说,基线就是你这个图标的底部和字母 g 的底部对齐,还是和字母 h 的底部对齐)。
基于这些特点,在普通的团队中使用自定义字体图标是相当困难的。不过好在还有不普通的团队,比如 FontAwesome,他们专门制作、维护了一组免费图标贡献给开源社区。如果你需要的图标恰好是其中之一,那么直接用就可以了,你需要做的只是引入它的 css 之后,在 html 中使用<i class="fa fa-home"></i>
。
当代:svg 图标
FontAwesome 虽好,但也不是万能的。它往往不足以融入 UX 的 Design System,而 UX 显然也不愿意削足适履,为了图标而改变自己的整体设计。
因此,对开发团队更友好的方式仍然应该是高度可定制的、方便单个处理的。如果能直接使用 UX 提供给我们的 svg 文件显然是最理想的。问题在于,该怎么用。这里面的门道可就多了。
在这种场景下雪碧图和 Data URL 仍然是可用的,因为它们只需要图片,而不管图片的格式,svg 也是图片,也有同样的优缺点 —— 但能支持视网膜屏。
不过,svg 的特点,让我们还有了一些另外的用法。
首先,可以把 svg 内联到 html 中。svg 和 html 在语法上非常像,都是 xml 语系,只是使用了不同的命名空间(xmlns),因此我们可以把 svg 作为一个元素内联到 html 中,现代浏览器可以正确地解释它们。这种用法比较自然,html 中引入的 css 也同样可以作用于 svg 内部的元素上,图文可以无缝整合在一起。
不过这种用法有两个问题。其一是 svg 中各个元素的 id 会并入页面的命名空间中,比如在 svg 中引用了一个名为 a 的过滤器,那么如果 html 或另一个 svg 中也定义了它,就会互相冲突。在稍大点的项目中要解决这种冲突会相当麻烦。其二是如果这个图标出现很多次,它的内容就会在 html 中重复很多遍,体积也会相应的增大。
好在,svg 有一种机制可以解决这个问题,也就是use
标签。使用 use 标签,你可以根据 id 引用本页面中的 svg 元素,甚至来自其它 svg 文件中的元素。比如要引用本页面中的 id 为 a
的 rect
元素,你只需要写 <use xlink:href="#a">
即可,并且在这里你可以指定自己的 svg 属性,以覆盖原始元素上的 svg 设置。这样一来,图标内容被重复很多遍的问题就解决了。如果写成 <use xlink:href="path/to/file.svg#a">
则可以引用外部文件 path/to/file.svg
中定义的元素,那么 id 冲突的问题也同样解决了,因为它们不在同一个命名空间。
不过,这种方式相对于字体图标还有两个缺点:
一是图标的颜色不会自动跟随文字颜色。比如原始元素定义的 rect 是红色的,那么无论你把它混排到什么颜色的文字中,它都是红色的。难道我们要在每个使用它的地方都手动覆盖一下颜色吗?当然不必,我们还有另一个特性可以解决这个问题,那就是 currentColor
。这是一个预定义的特殊颜色值,它的意思就是取当前的文字颜色。比如当你写<rect fill="currentColor"></rect>
时,把它混排到灰色文字中,这个rect
的填充色就是灰色的,混排到蓝色文字中就是蓝色的。而且,这个图标的其它部分你仍然可以指定特定的颜色,比如图标主体部分跟随文字颜色,而某个特殊区域总是显示为蓝色。经过这样的处理之后,你不但可以弥补相对于字体图标的缺点,还可以更进一步,支持彩色图标了!即使你不需要彩色图标,凭借 svg 对元素透明度的支持,也可以让你的图标比字体图标更加丰富多彩。
二是图标的大小不会自动跟随字体大小。不过这个就好解决了,因为 css 中有一个特性就是把当前字号作为尺寸单位,也就是 em
,比如图标大小设置为1em
就会让图标的实际尺寸跟当前字号一致。
当代:合字(Ligature)
你知道“囍”字吗?严格来说,它不是一个字,而是一个“合字”。也就是说这是两个汉字,只是显示成了一个字的样子。只是因为它非常常见,所以在字库中给了它一个单独的位置。但是大多数类似的文字是得不到这种特殊待遇的,比如“孔孟好学”的合体,以及“biangbiang面”中的“biang”字;字母上的声调(比如汉语拼音)也是合字。
那么,要如何用标准的方式来显示这些合字呢?实际上,现代的字体库早就已经支持合字了,只是在现实中用得不多,一般人没怎么注意罢了。不过,在图标领域,它重新找回了用武之地。我所知道的最早使用合字的图标体系是 Google 的 Material Design,比如用 <i class="material-icons">home</i>
就可以显示出一座房子,它是怎么工作的呢?实际上,material-icons
类为这个 i
元素指定了一个支持合字的字体库:'Material Icons'
,然后就会在字体库中检索出 home
这个合字对应的单字,并且把那个单字显示出来就可以了。换句话说,home
是某个单字的别名。
但是,我们为什么不像 FontAwesome 那样直接引用这个单字,而要用合字中转一次呢?在回答这个问题之前,我们先要知道一个概念,那就是:
访问互联网并不是我们这些健全人的专利!
世界上有很多残疾人,特别是视障人士,比如盲人、弱视等,甚至等我们老了都有可能加入他们的行列。他们访问互联网时难以像我们一样凭视觉阅读网页,而需要借助一种屏幕阅读器。
屏幕阅读器无法理解某个单字表示的是房子形状的图标,因此页面的编写者就需要给这个图标加上特殊的 aria-label
等属性,以便屏幕阅读器朗读它们。这称为 Accessibility(无障碍),简称 a11y。回想一下,你加过几个这种属性?很多人都不加,因为麻烦。但使用合字就不需要考虑这种问题了,因为合字本身就是可读的,在 html 中的写法就像普通文本一样。所以,你只要自然而然的使用合字,就已经满足了 a11y 的一些要求。
因此,虽然“合字”本身没有多少新的技术,但是我仍然把它归于“当代”,它值得作为一种趋势受到重视。
图标在开发中的其它方面
在实际的开发工作中,还有一些问题需要考虑。
第一个问题是摇树优化,也就是说,我们没有实际使用到的图标应该自动被优化掉,而不应该让我们手工检查哪些图标没用到,并且从源码中删掉。前面的大多数方案都难以给出完美的答案,只有内联 svg 方式是一个相对理想的方案。简单来说,写一个构建工具,当你在 html 中发现了一个 <img src="path/to/file.svg">
时,把这个 svg 文件的内容读出来,并且内联到 html 中。这样,只要一个文件从未被引用过,就会自动优化掉。如果你用基于 WebPack 的构建工具,可以引入我写的一个 “markup-inline-loader”。当然,如果你使用 Angular 这样的现代框架,你就不需要为此做什么额外的工作了。你只要把每个图标做成一个组件,使用 svg 内容作为模板,然后像普通组件一样引用它就可以了。Angular 会自动帮你优化掉没有引用过的组件。
第二个问题是 SPA。现代的前端应用基本上都是单页面应用(SPA),因此往往并不需要同时下载大量的图标,而是按需加载。因此,“古代”那种“一个图标一张图”的方式未必就真的不可接受,针对你的实际业务场景,做一下链路分析,它没准反倒是最合适的方案。
第三个问题是 svg 文件本身的优化。很多工具导出的 svg 文件很啰嗦,里面有很多对于显示没有意义的东西。一些 svg 图标即使减小到原来体积的一半儿都不会影响显示,因此,针对 svg 本身做一些优化也是有价值的。当然,这事不必手工来做,有一个现成的工具可以做这事,它叫做 svgo
,你只要运行 npm i -g svgo
命令就可以全局安装它了。你可以用 svgo
命令对单个文件或者整个目录做优化;可以手工使用,也可以把它集成到工具链里。
结语
这些图标技术,虽然出现时间上有先后,但并不是简单的替代关系,而是各有优缺点,适用于不同的场景。
随着需求和技术条件的变化,选型策略也要做出调整,有些时候还要混合使用,以发挥各自的优势。