3. 字体与图标压缩:字体子集化、SVG 图标合并与 Sprite 技术

字体和图标,往往是页面资源加载的「隐形杀手」。你想想看,一套完整的字体文件动辄几 MB,但实际用到的字符可能不到 1%。图标也是,一个页面几十个小图标,每个都单独发一次 HTTP 请求,那性能能好吗?

这一章,我们就来聊聊怎么给字体和图标「瘦身」。说白了,就是只保留你需要的,把多余的统统砍掉。

3.1 字体子集化:只保留你需要的字符

字体子集化,是我个人在项目中用得最多的优化手段之一。它的核心思路很简单:从完整的字体文件中,提取出你页面真正用到的字符,生成一个新的、更小的字体文件。

举个例子,你用了「思源黑体」做标题,但整个页面只用了 20 个汉字。那何必加载整个 5MB 的字体文件?子集化之后,可能就剩 50KB 了。

3.1.1 什么时候该做子集化?

  • 页面内容固定:比如官网、活动页、产品介绍页,文字内容基本不变。
  • 只用于标题或少量文字:正文用系统字体,标题用自定义字体。
  • 字体文件过大:超过 500KB 的字体,子集化收益非常明显。
注意: 如果页面内容是用户生成的(比如博客、论坛),或者文字会动态变化,那子集化就不太合适了。你无法预知用户会输入什么字符。

3.1.2 怎么做子集化?

我常用的工具是 fonttools 和 glyphhanger。前者是 Python 库,功能强大;后者是命令行工具,用起来更顺手。

这里我演示一下用 glyphhanger 的操作流程:

# 1. 先扫描页面,提取所有用到的字符
glyphhanger https://example.com --spider --css --json > used_chars.json

# 2. 根据提取的字符,生成子集字体
glyphhanger --subset=./original.ttf --US_ASCII --whitelist=甲乙丙丁戊己庚辛壬癸 --formats=woff2,woff

# 3. 输出结果:subset.woff2 和 subset.woff

嗯,这里要注意:--US_ASCII 会包含基本的英文字母和数字,--whitelist 则指定你需要的中文字符。如果你不确定页面用了哪些字,可以用 --spider 参数让工具自动爬取。

小技巧: 我个人习惯在构建流程中集成子集化。比如用 Webpack 的 fontmin-webpack 插件,或者写一个简单的 Node 脚本,在打包时自动处理字体文件。这样每次发布都不用手动操作。

3.1.3 子集化后的字体格式选择

格式 压缩率 浏览器支持 我的建议
WOFF2 最高(比 WOFF 小 30%-50%) 现代浏览器均支持 首选,体积最小
WOFF 中等 IE9+ 支持 作为 WOFF2 的降级方案
TTF/OTF 无压缩 全平台支持 不推荐用于 Web,仅做开发调试
EOT 低 仅 IE 支持 如果不需要兼容 IE8 以下,可以放弃

我在项目中通常只生成 WOFF2 和 WOFF 两种格式,然后用 @font-face 的 format() 提示浏览器优先加载 WOFF2。

@font-face {
  font-family: 'MySubsetFont';
  src: url('subset.woff2') format('woff2'),
       url('subset.woff') format('woff');
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

3.2 SVG 图标合并:从零散到统一

字体子集化解决的是字体文件的问题。那图标呢?以前大家喜欢用图标字体(比如 Font Awesome),但图标字体有个硬伤:它只能显示单色图标。对于需要多色、渐变、甚至动画的图标,SVG 才是更好的选择。

但 SVG 图标多了,每个都单独加载,HTTP 请求数就上去了。怎么办?合并。

3.2.1 什么是 SVG 图标合并?

简单说,就是把多个 SVG 图标放在一个文件里。每个图标用一个 <symbol> 或 <g> 包裹,并赋予唯一的 id。使用时,通过 <use> 标签引用对应的 id 即可。

这样做的好处很明显:

  • 减少请求数:所有图标一次加载,后续直接使用。
  • 可缓存:合并后的 SVG 文件可以设置长缓存时间。
  • 支持多色和动画:比图标字体灵活得多。

3.2.2 如何合并 SVG 图标?

我常用的工具是 svg-sprite 和 svgstore。前者是 Webpack 插件,后者是 Node 库,适合在构建流程中使用。

这里我用 svg-sprite 举个例子:

// webpack.config.js
const SVGSpriter = require('svg-sprite');

module.exports = {
  // ... 其他配置
  module: {
    rules: [
      {
        test: /\.svg$/,
        use: [
          {
            loader: 'svg-sprite-loader',
            options: {
              extract: true,
              spriteFilename: 'icons.svg'
            }
          }
        ]
      }
    ]
  }
};

配置好之后,在组件里直接引入 SVG 文件:

import iconHome from './icons/home.svg';
import iconUser from './icons/user.svg';

// 在模板中使用
<svg>
  <use xlink:href="#home" />
</svg>

构建后,所有图标会被合并到一个 icons.svg 文件中,页面只需加载一次。

核心思路: 将多个 SVG 文件合并成一个 Sprite 文件,通过 <use> 引用,减少 HTTP 请求,同时保留 SVG 的所有能力。

3.3 Sprite 技术:不只是图标

说到 Sprite,很多人第一反应是 CSS Sprite(雪碧图)。没错,那是把多个小图片合并成一张大图,通过 background-position 来定位。但 SVG Sprite 和它不太一样。

3.3.1 CSS Sprite vs SVG Sprite

对比项 CSS Sprite(雪碧图) SVG Sprite
文件类型 PNG/JPG 位图 SVG 矢量图
缩放 放大模糊 无限缩放,清晰
颜色 固定,难以修改 可通过 CSS 控制颜色、渐变
动画 不支持 支持 CSS 动画和 SMIL 动画
使用方式 background-position <use> 标签引用
维护成本 需要精确计算坐标 只需管理 SVG 文件

我个人更推荐 SVG Sprite。原因很简单:维护方便,效果更好。你想想看,CSS Sprite 每次新增或修改图标,都得重新拼图、重新计算坐标,太痛苦了。SVG Sprite 只需要把新的 SVG 文件丢进去,构建工具自动处理。

3.3.2 内联 SVG Sprite 的最佳实践

在实际项目中,我通常把 SVG Sprite 文件内联到 HTML 中,或者通过 JavaScript 动态注入。这样做的好处是:

  • 零额外请求:Sprite 文件直接嵌入页面,不产生网络请求。
  • 首屏可用:页面加载后,图标立即可用,没有加载延迟。
  • 便于缓存:如果 Sprite 内容不变,可以放在公共模板中,利用浏览器缓存。

内联的方式很简单:

<!-- 在页面头部或底部嵌入 Sprite -->
<svg style="display: none;">
  <symbol id="icon-home" viewBox="0 0 24 24">
    <path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
  </symbol>
  <symbol id="icon-user" viewBox="0 0 24 24">
    <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
  </symbol>
</svg>

<!-- 使用图标 -->
<svg class="icon">
  <use href="#icon-home" />
</svg>
注意: 如果 Sprite 文件较大(比如超过 50KB),内联到 HTML 中可能会增加首屏 HTML 的体积。这时候可以考虑异步加载 Sprite 文件,或者只内联首屏用到的图标,其余按需加载。

3.4 本章知识体系总览

为了让你更直观地理解这三部分的关系,我画了一张流程图:

字体与图标压缩知识体系 字体与图标压缩知识体系 资源压缩 字体子集化 提取字符 生成子集 WOFF2/WOFF SVG图标合并 symbol 定义 use 引用 SVG Sprite 核心目标:减少请求数 + 减小文件体积 最终效果:页面加载更快,用户体验更好

3.5 避坑指南与实战经验

讲完了理论和方法,我来分享几个实际项目中踩过的坑。

3.5.1 字体子集化的坑

我曾经在一个多语言项目中做字体子集化,只提取了中文和英文的字符。结果上线后,用户反馈某些页面上的数字显示异常。排查了半天才发现,原来那些数字是「全角数字」,和普通的「半角数字」编码不同,没有被包含在子集中。

所以,做子集化时,一定要把可能出现的字符都考虑进去。比如:

  • 全角/半角数字和标点
  • 特殊符号(©、®、™ 等)
  • 货币符号(¥、€、$ 等)
  • 常见的 emoji(如果页面支持)

3.5.2 SVG Sprite 的坑

我记得有一次,我用 svg-sprite-loader 合并图标,结果发现某些图标在页面上显示不出来。打开控制台一看,报错了:Unsafe attempt to load URL。原来是 SVG 文件里引用了外部资源(比如一个图片),被浏览器的安全策略拦截了。

解决方案很简单:确保所有 SVG 图标都是自包含的,不要引用任何外部资源。如果图标里需要图片,直接把图片转成 base64 嵌入 SVG 中。

另一个常见问题: SVG 中的 viewBox 属性不一致。如果合并的图标 viewBox 大小不同,显示时可能会出现偏移或缩放异常。建议所有图标统一使用相同的 viewBox(比如 0 0 24 24),或者在 <use> 时手动设置宽高。

3.5.3 性能权衡

字体子集化和 SVG Sprite 虽然能显著减少资源体积,但也不是万能的。你需要根据项目实际情况做权衡:

  • 小项目:如果页面只有几个图标,直接内联 SVG 代码可能比做 Sprite 更简单。
  • 大项目:如果图标数量超过 20 个,Sprite 的收益就很明显了。
  • 动态内容:如果页面文字是动态生成的,字体子集化可能不适用,可以考虑用系统字体或可变字体。

嗯,说到底,优化的核心是「按需加载」。你不需要的东西,就不要让它出现在用户的浏览器里。这个道理,放在字体和图标上,再合适不过了。