☰
Web字体加载优化指南:从子集化到加载策略的四大实操方案
2026/9/29 15:41:39 网站建设 项目流程

网站打开时等了半天,最后页面是显示出来了,但整段文字先是空着,然后突然“唰”一下换了个字形——这种体验想必做前端的都不陌生。最近一个周末,我翻旧项目的性能报告,发现图片、脚本都优化过一轮了,字体这个隐形大户还赖在首屏成本里没走。Web字体加载这个事,确实值得拿出来重新盘一盘。

这篇杂谈我打算把Web字体加载的优化方案整理成四大类,分别对应四类最常见的问题:字体文件太大、格式太老旧、用到和没用到的字符全塞一起、浏览器加载的时机太笨。每个方案都会给到能直接抄的配置和命令,也会把我实际踩过的坑一并交代。适合正在做页面性能优化、被Lighthouse低分困扰,或者想在弱网环境下改善首屏体验的朋友参考。

1. 先弄明白问题在哪:字体加载为什么拖慢页面

1.1 字体通常躲过了你的优化清单

做性能优化的时候,大家的注意力往往集中在图片压缩、JS分包、接口合并这些地方。字体这块除非已经到了严重影响体验的地步,否则很少会进第一轮优化清单。但实际打开DevTools的Network面板看一眼就会发现,一套字体的传输体积经常排在页面资源的前三名。

拿一个很典型的场景举例:站内用了一套自定义中文字体,未压缩的TTF可能到2MB到4MB,即使转成WOFF2之后依然有800KB到1.5MB。这个体积放在移动端4G网络下,意味着约200ms到500ms的额外下载时间,如果用户还在弱网环境,这个时间会被放大到几秒。后果就是首屏渲染的关键资源里,字体成了和图片一样重的“背景型成本”。

而且字体有个比较隐蔽的特点——它影响的是整站文字,不像图片只影响某个区域。字体加载慢,页面所有文字都要跟着等,这也就是为什么字体在网页性能里往往“小小的体积也能造成大大的问题”。

1.2 渲染阻塞与FOIT/FOUC:用户看到的三种尴尬

字体加载慢,直接影响用户的体验形态主要分三种。

  • FOIT(Flash of Invisible Text,不可见文字闪烁):浏览器在字体下载完成之前,会先隐藏该字体的文字,用户看到的就是一段空白。Chrome默认的font-display:auto策略下,这个等待时间最长约3秒,3秒后如果还没下完,就退回备用字体显示。
  • FOUC(Flash of Unstyled Text,无样式文字闪烁):字体加载完成后,页面文字从备用字体瞬间换成目标字体,用户会看到一段文字“跳变”,特别是在中文场景下,备用字体和目标字体的字形宽度差异明显,跳变感更强。
  • 混合形态:先空白,再显示备用字体,最后又跳成目标字体,屏幕上的文字反复变化,移动端尤其明显。

我在实际的移动端项目中见过最极端的反馈是“页面打开就一坨空白,半天才出字”。其实后台脚本和接口都很快,就是字体卡住了首屏的文字渲染。这也是为什么Web字体加载不能只靠后端加缓存解决,必须从策略层面干预加载行为。

2. 方案一:字体子集化——只加载用户真正用到的字符

2.1 子集化原理:字形表才是字体体积的大头

字体文件的体积绝大部分来自字形表(glyf表),每一个字符对应一个或几个字形描述,中文字体的字形尤其复杂,一个汉字轮廓可能是几十个甚至上百个控制点,所以中文字体天生就比西文字体大。

但实际页面上用到的字符是有限的。一个网站的正文、标题、按钮文案,刨去动态用户内容,可能只用到了几百个常用汉字和几十个西文字符。而完整中文字体可能包含超过两万个字形,也就是说,你可能花了2MB流量把一万九千多个根本用不到的字形下载回来。

字体子集化(subset)的思路就是:只从原字体中提取出指定字符对应的字形,重新打包成一个新的字体文件。这相当于把一本两万词的词典裁剪成一张常备单词卡,体积缩小程度非常可观。我的实测数据是:一个包含常见西文和约600个常用汉字的子集字体,WOFF2格式压缩后体积在20KB到40KB之间,而原始中文字体是2MB多,差了将近百倍。

2.2 实操:用glyphhanger和fonttools生成子集字体

子集化工具我常用的有两个:glyphhanger和fontTools的subset命令。glyphhanger更自动化,能直接从HTML或CSS里提取用到的字符;fontTools的subset命令更灵活,适合做精细化控制。

先看glyphhanger的用法,它需要本地有Python环境和fonttools库:

npm install -g glyphhanger glyphhanger ./dist/**/*.html --subset=./fonts/source.woff2 --formats=woff2,woff --output=./fonts/subset/

这个命令会扫描dist下所有HTML文件里出现的字符,然后用source.woff2截取对应字形,输出成woff2和woff两种格式。比较方便的是它会自动去重并处理CSS里的unicode-range,不过它对动态渲染出的内容是无能为力的。

再来看fontTools的subset命令,这个可控性更高:

python -m fontTools.subset ./fonts/source.ttf \ --output-file=./fonts/subset.woff2 \ --flavor=woff2 \ --text="页面中出现的字符,比如:这是一段示例文本Web字体加载优化方案" \ --unicodes=U+0020-007E,U+2000-206F,U+3000-303F,U+4E00-9FA5 \ --layout-features='*'

说明一下几个参数的意义:

  • --text:直接把字符写出来,适合固定文案较少的场景。
  • --text-file:从一个文本文件里读取字符集合,适合从整站抓取的文案。
  • --unicodes:按Unicode码位范围圈选,适合保留某个区间内的所有字符,比如U+4E00-9FA5是常用的CJK统一汉字区。
  • --flavor=woff2:指定输出WOFF2格式。
  • --layout-features='*':保留所有OpenType布局特性,比如连字、大小写敏感形式等,这个在拉丁字体中比较重要,建议保留。

2.3 注意事项:子集化的三个坑

第一,动态内容很容易被漏掉。用户昵称、评论区、富文本内容这类动态文本,如果包含子集外字符,页面上就会出现方框或缺字。常见的做法是:业务运营人员在后台维护一个“核心文案库”的动态集,构建时把文案库和前端代码拼接成text-file,再配合一个覆盖率较高的备用字体兜底。

第二,粗体和斜体要单独做子集。有些字体在加粗时并不仅仅是“同一个字形加粗描边”,而是有独立的粗体字形。子集化时要特别注意对每个字重(weight)分别执行subset,不要只处理regular字重,然后让浏览器去伪造加粗效果,这样在Windows下会出现“伪粗体”的模糊效果。

第三,数字的宽度特性。部分字体包含表格数字(tabular figures)、旧式数字等OpenType特性,用于表格对齐或排版。如果页面有数字统计、价格展示类的内容,子集化时要把对应的特性标记(如tnum、lnum)保留进layout-features,否则数字在切换字体时宽度会变,造成布局跳动。

3. 方案二:格式升级与多格式降级——先把压缩率吃满

3.1 WOFF2为什么能压缩得更好

字体文件在网络上的传输格式经历了TTF/OTF、WOFF、WOFF2几个阶段。TTF/OTF是原始轮廓格式,没有专门针对网络传输做压缩。WOFF在TTF基础上用Flate做了通用压缩,效果一般。WOFF2则做了本质变化:它先用Brotli对字体数据进行二次压缩,同时把字体内部的glyf表、loca表等做了重新编码和变换,让压缩算法能更高效地工作。

我手头一个思源黑体的测试数据可以说明问题:原始TTF约7.2MB,转WOFF后约4.1MB,转WOFF2后约2.5MB。也就是说,同样一个字体,WOFF2比WOFF再省约40%的传输体积。换来的是编码和解码时多一点点CPU开销,这在当前终端设备上完全不是瓶颈。

3.2 @font-face多格式的最佳写法

虽然WOFF2已经是现代浏览器的绝对主流,但兼容性上还是需要留一条后路。@font-face声明里可以同时列出多个格式,浏览器会按顺序选择第一个能识别的格式,不会重复下载。

@font-face { font-family: 'MyFont'; src: url('/fonts/myfont.woff2') format('woff2'), url('/fonts/myfont.woff') format('woff'), url('/fonts/myfont.ttf') format('truetype'); font-display: swap; font-weight: 400; font-style: normal; unicode-range: U+0020-007E, U+4E00-9FA5; }

这里有个细节很多人会忽略:format()描述符一定要写对。如果format()标示的格式和实际文件不符,浏览器可能下载下来才发现解析不了,白白浪费一次请求。如果目标用户群体里完全没有老版本IE,其实可以只保留woff2和woff两个格式。

3.3 服务端也要跟上:Content-Type与缓存基建

字体格式升级不只是CSS的事,服务端如果返回错误的Content-Type,浏览器一样可能拒绝渲染。WOFF2的Content-Type是font/woff2,WOFF是font/woff,需要在服务器配置里加好。以Nginx为例:

location /fonts/ { add_header Cache-Control "public, max-age=31536000, immutable"; default_type font/woff2; }

我建议对字体资源使用版本化文件名,比如myfont-a1b2c3.woff2。这样能让CDN和浏览器把字体当成不可变资源,长期缓存,后续发布新版本时只要改文件名引用即可。

4. 方案三:unicode-range拆分加载——按区块送字体

4.1 unicode-range的工作方式

子集化解决了“文件太大”的问题,但它有一个局限:如果页面实际用到的字符很多,比如整站是UGC内容,或者需要覆盖较全的常用汉字,那单个子集文件还是会膨胀到几百KB甚至更大。这种情况下,更合适的做法是拆分。

unicode-range是CSS@font-face规则里的一个描述符,它告诉浏览器这段字体规则适用于哪些Unicode字符区间。浏览器解析页面时,会根据DOM里实际出现的字符,去匹配对应的@font-face规则,只下载那些真正“命中”的字体分片。

举个例子,如果把中文字体拆成“基础拉丁”“常用汉字”“扩展汉字”三个分片,页面里如果只有基础拉丁和常用汉字,浏览器就只下载两个分片,扩展汉字分片不会被请求。这个机制天生适合中文站,因为中文的Unicode码位本身就能按区块做很好的划分。

4.2 实操:怎么拆、拆多少、用什么工具

拆分方案一般有两种做法。

第一种是先用fonttools切出多个子集文件,再在CSS里分别声明。比如把Unicode按下面几个区段切:

  • U+0020-007E:基础拉丁(ASCII可见字符)
  • U+3000-303F:中文标点和CJK符号
  • U+4E00-52FF:常用汉字区段一
  • U+5300-9FFF:常用汉字区段二
  • U+F900-FAFF:兼容表意文字区

命令示例:

python -m fontTools.subset ./fonts/source.ttf \ --output-file=./fonts/latin.woff2 \ --flavor=woff2 \ --unicodes=U+0020-007E,U+2000-206F python -m fontTools.subset ./fonts/source.ttf \ --output-file=./fonts/han-1.woff2 \ --flavor=woff2 \ --unicodes=U+4E00-52FF

对应的CSS:

@font-face { font-family: 'MyCJKFont'; src: url('/fonts/latin.woff2') format('woff2'); unicode-range: U+0020-007E, U+2000-206F; } @font-face { font-family: 'MyCJKFont'; src: url('/fonts/han-1.woff2') format('woff2'); unicode-range: U+4E00-52FF; }

这里要注意:多个@font-face使用同一个font-family名字,分别指定不同的unicode-range,浏览器会当成同一个字族的多个“分片”来处理。

第二种是用glyphhanger自动提取页面字符后,按出现频率分组。它可以接受--U=0-7E这种Unicode范围参数,适合内容相对可控的场景。

4.3 拆分的坑:请求数膨胀与兼容性

拆分看起来美好,但分片数量一旦失控,副作用就很明显。每个分片都是一个独立的HTTP请求,如果拆出十几个分片,页面首次加载的字体请求数量就会翻倍,甚至触发浏览器对同域并发连接数的限制。中文站一般拆2到4个分片就够了,不要贪多。

还有一个兼容性问题需要提醒:部分老版本浏览器(尤其是Android低版本WebView)对unicode-range的支持不完整,会把所有分片都下载下来。如果这些分片数量多、体积大,反而会变成灾难。前两年我在一个中老年用户为主的App内嵌H5里就遇到过这个情况。做兼容的时候,可以先用请求量统计判断目标用户群的浏览器分布,再决定是否使用拆分方案。

我个人的经验是:如果目标字体子集化后的总大小能控制在100KB以内,优先做一个整体子集文件,不要在拆分的复杂度上花钱;只有确实需要覆盖数千个汉字时,才有必要走拆分路线。

5. 方案四:加载时机与渲染策略——不让用户干等字体

5.1 font-display的四个取值怎么选

等字体下完再显示文字,这是浏览器默认的耐心策略,但用户没有这个耐心。font-display就是用来控制这个等待行为的。

取值行为适用场景
auto由浏览器决定,Chrome里基本同block不推荐,行为不可控
block短暂隐藏文字等待字体,一般约3秒品牌Logo、图标字体等必须显示指定字体的场景
swap先用备用字体显示,字体加载完成后替换正文、标题、内容型文本
fallback约100ms内等待字体,等不到就用备用字体,之后有短暂替换窗口介于block和swap之间,适合不想长时间空白又想换取字体的场景
optional等待时间极短,网络慢时直接用备用字体,不影响后续页面行为装饰性字体、对品牌影响小的场景

我在实际项目里的默认建议是:正文和标题用swap,在弱网环境下体验最稳;品牌诉求特别强的场景用block;纯装饰性且可有可无的字体用optional,它还能让浏览器在系统负载较高时直接跳过字体加载,不干扰主内容。

5.2 preload与preconnect:把网络请求早一点发出去

font-display是在等待行为上做文章,实际网络请求的早晚同样关键。字体资源的preload能让浏览器尽早发现并下载关键字体,不再等到CSS解析到@font-face时才发起请求。

<link rel="preload" as="font" type="font/woff2" href="/fonts/myfont-a1b2c3.woff2" crossorigin>

这里有一个非常容易踩坑的细节:preload字体资源时必须带上crossorigin属性,即使字体文件就在你自己的域名下也一样。原因是字体请求天然以CORS模式发起,preload如果不带crossorigin,浏览器会以普通模式请求一次,之后字体实际加载时又按CORS模式再请求一次,Network面板里就能看到同一个字体文件出现两次请求。我见过不止一次这个坑导致的“字体文件下载了两次”。

如果字体放在CDN域名上,preconnect也值得加:

<link rel="preconnect" href="https://cdn.example.com" crossorigin>

它的作用是提前完成DNS查询、TCP握手和TLS握手,减少后续字体请求建连的时间成本。要注意preconnect不要加太多域名,每加一个域名都是有成本的,对首屏而言控制在一个到两个字体域名即可。

5.3 FontFace API:把加载节奏握在自己手里

font-display能控制等待行为,但有一些场景需要更精细的控制:比如某个字体只用于文章正文区,用户滚动到文章前不急着加载;又或者某个特殊字体只出现在用户登录后的头像框里。这个时候可以用JavaScript的FontFace API主动管理。

const fontFace = new FontFace('MyFont', 'url(/fonts/myfont.woff2)', { weight: '400', style: 'normal' }); fontFace.load().then((loadedFont) => { document.fonts.add(loadedFont); document.body.classList.add('font-loaded'); }).catch((err) => { console.error('字体加载失败', err); });

加载完成后,再通过一个class让字体真正生效:

body.font-loaded { font-family: 'MyFont', sans-serif; }

这种做法的好处是:字体加载完全不阻塞首屏渲染,首屏先用系统字体,等自定义字体加载完成后再平滑切换。如果配合document.fonts.ready或document.fonts.load('16px MyFont')做细粒度控制,甚至可以做到字体加载一半时开始替换,体验更顺滑。

5.4 回退字体与度量对齐:减少替换时的布局位移

font-display: swap有一个众所周知的问题:字体加载完成后替换字形,如果两种字体的度量不一致,文字的宽高就会变化,导致布局位移(CLS)。这在国内站点常见的“字体从微软雅黑切换到自定义字体”场景中尤其明显,因为中文字形的字宽差异很大。

解决方案是CSS字体度量覆盖属性。在@font-face里设置size-adjust、ascent-override、descent-override、line-gap-override这几个值,可以修正备用字体与目标字体的度量差异。

@font-face { font-family: 'MyFont Fallback'; src: local('PingFang SC'), local('Microsoft YaHei'); size-adjust: 95%; ascent-override: 85%; descent-override: 20%; line-gap-override: 10%; }

这几个值的获取比较专业,通常需要通过工具对比目标字体和备用字体的度量数据。我用的比较多的办法是先用一套线上工具算出推荐值,然后实际渲染测试微调。一个更“省心”的做法是:在内容区优先使用和目标字体字形宽度接近的系统字体,把CLS控制在可接受范围,而不是追求100%一致。

6. 常见问题与排查技巧实录

6.1 字体文件被下载两次

这是我在排查中遇到频率最高的问题。现象是Network面板里同一个字体出现两个请求,一个来自preload,一个来自CSS里的@font-face。

核心原因基本就是上面提到的crossorigin缺失。检查点有两个:第一,preload标签是否带了crossorigin;第二,CDN响应头是否包含Access-Control-Allow-Origin。只要这两处配置正确,字体就不会重复下载。

6.2 页面出现方框或缺字

这个问题基本可以断定是子集化白名单没覆盖到动态内容。排查的时候先用DevTools选中缺字字符,看Elements面板里对应文本节点的字体族计算样式;再检查字体子集包含的Unicode范围。如果确实是缺字符,就别只修当前页面了,把用户产生内容的字符集并入构建时的text-file,否则下次还会在别的地方冒出来。

6.3 字体明明加载了,页面文字还是备用字体

这种情况常见于font-display: optional。这个策略下,如果字体加载时间超过约100ms,浏览器会直接放弃替换,即使后来字体加载完成也保持备用字体样式,目的是节省用户流量和CPU。如果你希望字体最终一定能生效,就不要对关键字体使用optional,改用swap或fallback。

还有一种情况是使用FontFace API加载时,字体对象没正确添加到document.fonts,只有load()但没有add(),导致字体虽然加载完成,但页面样式表里的font-family引用不到它。

6.4 四个方案的组合顺序与效果验证

把四大方案全部端上来容易眼花,实际落地时我建议按这个顺序来:

第一,先做子集化,解决体积问题。这是收益最大、成本最低的一步,通常能把字体体积缩小90%以上。第二,升级为WOFF2格式,并在服务端配好Content-Type和长缓存。第三,如果子集化后体积仍然超过200KB或者覆盖字符集很广,再考虑按unicode-range拆分,但要严格控制分片数量。第四,最后配置font-display、preload和回退字体度量,提升用户感知层面的体验。

效果验证我通常看四个指标:字体请求数量、总传输体积、DOMContentLoaded和Largest Contentful Paint、以及CLS值。Lighthouse里也会直接给出与字体相关的审计项,比如“Ensure text remains visible during webfont load”就是检查font-display配置的。一个可以分享的极端案例:一个中文官网的字体原始体积为2.3MB,经过子集化后按首屏清单生成约28KB的常用子集、120KB的次常用子集和40KB的拉丁子集,总传输体积从2.3MB降到188KB,加载耗时降低了约75%。

最后说几句

把这套流程完整跑过一遍之后,我最大的感受是:Web字体加载的优化,本质上不是“压到多小”的问题,而是“什么时候加载、以什么优先级加载、用什么策略兜底”的问题。很多人只盯着文件大小,却忘了font-display和preload这些加载时机相关的参数同样能带来成倍的体验差异。我自己的教训是涉过一回把所有中文字体分片全部preload的坑,结果首屏带宽被字体请求占满,图片和脚本全部排队,反而拖慢了整体渲染进度。字体优化没有一劳永逸的配置,内容每次更新、新增动态模块时,都要记得重新审视子集清单和加载策略。希望这篇杂谈里踩过的坑和整理出的方案,能帮你少走一点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询