Web字体加载优化指南:从子集化到加载策略的四大实操方案
网站打开时等了半天最后页面是显示出来了但整段文字先是空着然后突然“唰”一下换了个字形——这种体验想必做前端的都不陌生。最近一个周末我翻旧项目的性能报告发现图片、脚本都优化过一轮了字体这个隐形大户还赖在首屏成本里没走。Web字体加载这个事确实值得拿出来重新盘一盘。这篇杂谈我打算把Web字体加载的优化方案整理成四大类分别对应四类最常见的问题字体文件太大、格式太老旧、用到和没用到的字符全塞一起、浏览器加载的时机太笨。每个方案都会给到能直接抄的配置和命令也会把我实际踩过的坑一并交代。适合正在做页面性能优化、被Lighthouse低分困扰或者想在弱网环境下改善首屏体验的朋友参考。1. 先弄明白问题在哪字体加载为什么拖慢页面1.1 字体通常躲过了你的优化清单做性能优化的时候大家的注意力往往集中在图片压缩、JS分包、接口合并这些地方。字体这块除非已经到了严重影响体验的地步否则很少会进第一轮优化清单。但实际打开DevTools的Network面板看一眼就会发现一套字体的传输体积经常排在页面资源的前三名。拿一个很典型的场景举例站内用了一套自定义中文字体未压缩的TTF可能到2MB到4MB即使转成WOFF2之后依然有800KB到1.5MB。这个体积放在移动端4G网络下意味着约200ms到500ms的额外下载时间如果用户还在弱网环境这个时间会被放大到几秒。后果就是首屏渲染的关键资源里字体成了和图片一样重的“背景型成本”。而且字体有个比较隐蔽的特点——它影响的是整站文字不像图片只影响某个区域。字体加载慢页面所有文字都要跟着等这也就是为什么字体在网页性能里往往“小小的体积也能造成大大的问题”。1.2 渲染阻塞与FOIT/FOUC用户看到的三种尴尬字体加载慢直接影响用户的体验形态主要分三种。FOITFlash of Invisible Text不可见文字闪烁浏览器在字体下载完成之前会先隐藏该字体的文字用户看到的就是一段空白。Chrome默认的font-display:auto策略下这个等待时间最长约3秒3秒后如果还没下完就退回备用字体显示。FOUCFlash 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 --formatswoff2,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 \ --flavorwoff2 \ --text页面中出现的字符比如这是一段示例文本Web字体加载优化方案 \ --unicodesU0020-007E,U2000-206F,U3000-303F,U4E00-9FA5 \ --layout-features*说明一下几个参数的意义--text直接把字符写出来适合固定文案较少的场景。--text-file从一个文本文件里读取字符集合适合从整站抓取的文案。--unicodes按Unicode码位范围圈选适合保留某个区间内的所有字符比如U4E00-9FA5是常用的CJK统一汉字区。--flavorwoff2指定输出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: U0020-007E, U4E00-9FA5; }这里有个细节很多人会忽略format()描述符一定要写对。如果format()标示的格式和实际文件不符浏览器可能下载下来才发现解析不了白白浪费一次请求。如果目标用户群体里完全没有老版本IE其实可以只保留woff2和woff两个格式。3.3 服务端也要跟上Content-Type与缓存基建字体格式升级不只是CSS的事服务端如果返回错误的Content-Type浏览器一样可能拒绝渲染。WOFF2的Content-Type是font/woff2WOFF是font/woff需要在服务器配置里加好。以Nginx为例location /fonts/ { add_header Cache-Control public, max-age31536000, immutable; default_type font/woff2; }我建议对字体资源使用版本化文件名比如myfont-a1b2c3.woff2。这样能让CDN和浏览器把字体当成不可变资源长期缓存后续发布新版本时只要改文件名引用即可。4. 方案三unicode-range拆分加载——按区块送字体4.1 unicode-range的工作方式子集化解决了“文件太大”的问题但它有一个局限如果页面实际用到的字符很多比如整站是UGC内容或者需要覆盖较全的常用汉字那单个子集文件还是会膨胀到几百KB甚至更大。这种情况下更合适的做法是拆分。unicode-range是CSSfont-face规则里的一个描述符它告诉浏览器这段字体规则适用于哪些Unicode字符区间。浏览器解析页面时会根据DOM里实际出现的字符去匹配对应的font-face规则只下载那些真正“命中”的字体分片。举个例子如果把中文字体拆成“基础拉丁”“常用汉字”“扩展汉字”三个分片页面里如果只有基础拉丁和常用汉字浏览器就只下载两个分片扩展汉字分片不会被请求。这个机制天生适合中文站因为中文的Unicode码位本身就能按区块做很好的划分。4.2 实操怎么拆、拆多少、用什么工具拆分方案一般有两种做法。第一种是先用fonttools切出多个子集文件再在CSS里分别声明。比如把Unicode按下面几个区段切U0020-007E基础拉丁ASCII可见字符U3000-303F中文标点和CJK符号U4E00-52FF常用汉字区段一U5300-9FFF常用汉字区段二UF900-FAFF兼容表意文字区命令示例python -m fontTools.subset ./fonts/source.ttf \ --output-file./fonts/latin.woff2 \ --flavorwoff2 \ --unicodesU0020-007E,U2000-206F python -m fontTools.subset ./fonts/source.ttf \ --output-file./fonts/han-1.woff2 \ --flavorwoff2 \ --unicodesU4E00-52FF对应的CSSfont-face { font-family: MyCJKFont; src: url(/fonts/latin.woff2) format(woff2); unicode-range: U0020-007E, U2000-206F; } font-face { font-family: MyCJKFont; src: url(/fonts/han-1.woff2) format(woff2); unicode-range: U4E00-52FF; }这里要注意多个font-face使用同一个font-family名字分别指定不同的unicode-range浏览器会当成同一个字族的多个“分片”来处理。第二种是用glyphhanger自动提取页面字符后按出现频率分组。它可以接受--U0-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 relpreload asfont typefont/woff2 href/fonts/myfont-a1b2c3.woff2 crossorigin这里有一个非常容易踩坑的细节preload字体资源时必须带上crossorigin属性即使字体文件就在你自己的域名下也一样。原因是字体请求天然以CORS模式发起preload如果不带crossorigin浏览器会以普通模式请求一次之后字体实际加载时又按CORS模式再请求一次Network面板里就能看到同一个字体文件出现两次请求。我见过不止一次这个坑导致的“字体文件下载了两次”。如果字体放在CDN域名上preconnect也值得加link relpreconnect hrefhttps://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的坑结果首屏带宽被字体请求占满图片和脚本全部排队反而拖慢了整体渲染进度。字体优化没有一劳永逸的配置内容每次更新、新增动态模块时都要记得重新审视子集清单和加载策略。希望这篇杂谈里踩过的坑和整理出的方案能帮你少走一点弯路。