字体为什么会让你的网站"闪一下"
很多人做网站优化的顺序是:先压图片,再上 CDN,最后才想起来看字体。但如果你打开 Chrome 的 Lighthouse,或者在真实用户里观察 Core Web Vitals,会发现一个很反直觉的事实——字体往往是首屏渲染里最大的隐形拖累。表现就是两种:要么页面先白一下再"跳"出文字(FOIT,Flash of Invisible Text),要么先用系统默认字体显示,等网络字体到了再"闪"一下换成新字体(FOUT,Flash of Unstyled Text)。前者的代价是用户看不到内容,后者则直接贡献了 CLS(累积布局偏移)指标。
更麻烦的是中文字体。一个完整的思源黑体 woff2 动辄好几 MB,因为中文字符集有几万个字,而网页实际用到的可能只有几百个。把几 MB 的字体直接 @font-face 上去,首屏基本就废了。这篇文章从头讲清楚字体加载的优化路径:子集化、格式选择、font-display、preload,以及怎么和 CLS 防治配合。
第一步:搞清楚你真的需要网络字体吗
在动手优化之前,先问自己一个问题:这个字体非用不可吗?
- 如果只是正文,浏览器自带的系统字体栈(
-apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif)已经足够好,零加载、零闪烁,性能和观感都是最优解。 - 网络字体的价值在于品牌识别——标题、Logo、特殊设计字体。这类用途的字符量通常很少,天然适合子集化。
所以原则是:正文用系统字体,标题和品牌字用子集化网络字体。把有限的加载预算花在刀刃上。
第二步:字体格式与子集化
格式演进:从 ttf 到 woff2
字体格式的压缩率差异巨大,同一款字体:
| 格式 | 相对体积 | 说明 |
|---|---|---|
| TTF / OTF | 100% | 原始格式,不要直接用于网页 |
| WOFF | 约 60% | zlib 压缩,兼容老浏览器 |
| WOFF2 | 约 30% | Brotli 压缩,现代浏览器全支持 |
结论很直接:只保留 WOFF2。IE 早就死了,WOFF2 的浏览器支持率超过 97%,为剩下不到 3% 的流量付出双倍存储不划算。
子集化:把几 MB 砍到几十 KB
子集化的核心是:只保留网页真正会用到的字符。工具首选 Google 官方的 fonttools 里的 pyftsubset。
# 安装
pip install fonttools brotli
# 只保留常用汉字 + 拉丁字母 + 数字标点
pyftsubset source.otf \
--output-file=site-subset.woff2 \
--flavor=woff2 \
--unicodes="U+0020-007E,U+4E00-9FFF,U+3000-303F,U+FF00-FFEF" \
--layout-features='*' \
--no-hinting上面这条命令会保留全部 CJK 基本区(U+4E00-9FFF,约两万多个字),适合正文也可能用到的情况,体积通常能从几 MB 降到 1MB 以内。如果只是标题用,字符集更小,可以只列出实际用到的字:
# 从文案里提取实际用到的字符(去重)
python3 - <<'PY'
import re
text = open("titles.txt", encoding="utf-8").read()
chars = sorted(set(text) - set("\n\r"))
open("chars.txt", "w", encoding="utf-8").write("".join(chars))
print("unique chars:", len(chars))
PY
pyftsubset source.otf \
--output-file=title.woff2 \
--flavor=woff2 \
--text-file=chars.txt \
--layout-features='*'标题字集通常只有几百个字符,压出来往往只有 30 到 80KB,加载代价可以忽略不计。这就是"子集化"最大的价值:把用不到的字,提前砍掉。
分片方案:unicode-range 按需加载
如果内容会持续更新(比如博客每天发新文章),固定子集就会缺字。这时候可以用 Google Fonts 那套"分片"思路——把一个字体切成很多片,每片用 unicode-range 声明覆盖的字符。浏览器只会下载"当前页面实际出现了对应字符"的分片。
@font-face {
font-family: "SiteFont";
src: url("/fonts/site-cjk-1.woff2") format("woff2");
font-weight: 400;
font-display: swap;
unicode-range: U+4E00-4E7F;
}
@font-face {
font-family: "SiteFont";
src: url("/fonts/site-cjk-2.woff2") format("woff2");
font-weight: 400;
font-display: swap;
unicode-range: U+4E80-4EFF;
}
/* ...以此类推,切个几十片 */这个方案的好处是"永远够用"——不用维护字符表,新字自动落在某个分片里。缺点是文件多、请求多,HTTP/2 下影响不大,但要确保 Nginx 对字体文件开了长缓存。
第三步:font-display 的四个值怎么选
font-display 决定字体没加载完时浏览器怎么办,它有四个值,理解它们的差别是解决 FOIT 和 FOUT 的关键:
| 值 | 行为 | 适用场景 |
|---|---|---|
| auto | 等同 block,浏览器默认 | 不推荐显式使用 |
| block | 最长 3 秒不可见,之后回退 | 会产生 FOIT,避免 |
| swap | 立刻用回退字体,字体到达后替换 | 正文首选,但有 CLS 风险 |
| fallback | 100ms 极短阻塞,之后回退且不再替换 | 折中方案 |
| optional | 几乎不阻塞,慢了就本次不用 | 弱网优先,无 CLS |
实战建议:品牌标题字用 swap(宁可闪一下也要显示对),对 CLS 极度敏感的场景用 optional。如果想彻底消灭闪烁,还有一个技巧——先测量目标字体的 metrics,用 size-adjust 让回退字体的尺寸和网络字体对齐:
@font-face {
font-family: "SiteFont";
src: url("/fonts/title.woff2") format("woff2");
font-display: swap;
size-adjust: 105%; /* 与回退字体对齐,减轻跳动 */
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}这几个 override 参数能显著降低字体切换时的布局偏移,是解决"文字换字体时跳一下"最有效的手段之一。
第四步:preload 与加载优先级
网络字体是"藏在 CSS 里"的资源,浏览器要先解析 CSS 才知道要下载它,这中间有几百毫秒的延迟。用 <link rel="preload"> 可以提前告诉浏览器:
<link rel="preload" href="/fonts/title.woff2" as="font" type="font/woff2" crossorigin>三个要点,缺一不可:
as="font"——告诉浏览器资源类型,否则会被当成普通资源,优先级不对。type="font/woff2"——不支持 woff2 的浏览器就不会浪费带宽去下。crossorigin——最容易被漏掉的一条。字体请求默认是 CORS 模式,漏掉这个属性会导致 preload 和实际请求各下一份,等于没优化还浪费了带宽。
另外,preload 只对"首屏关键字体"用,一般一到两个就够了。给整个字体家族(regular/medium/bold)全上 preload,会挤占其他关键资源的带宽,反而拖慢 LCP。
第五步:服务端配合(Nginx)
前端做得再对,服务端没配好也白搭。字体文件有三个必须处理的点:
# /etc/nginx/mime.types 确保有这一行(通常自带)
font/woff2 woff2;
# 站点配置里,给字体目录开长缓存 + 跨域
location ~* \.(woff2?|ttf|otf|eot)$ {
expires 365d;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
access_log off;
}- 正确 MIME 类型:类型不对,浏览器可能拒绝加载或警告。
- 长缓存 + immutable:字体内容几乎不变(文件名带 hash 或用版本号),可以放心设一年。
- CORS 头:如果字体和页面不同源(比如用了 CDN 域名),必须加
Access-Control-Allow-Origin,否则字体会被静默拦截,控制台才会报Access to font blocked by CORS。
验证 MIME 类型和缓存头是否正确:
curl -sI "https://你的域名/fonts/title.woff2" | grep -iE "content-type|cache-control|access-control"CLS 防治:字体切换不跳动的完整思路
字体引起的 CLS,本质是"回退字体"和"网络字体"的字宽、行高不同,替换瞬间把后面的内容顶走了。防治分三层:
- 选一个 metrics 接近的回退字体。中文用
"PingFang SC"或"Microsoft YaHei",英文用system-ui,让回退和目标的"胖瘦"尽量接近。 - 用 size-adjust 等 override 参数校准,前面已讲。
- 给文字容器预留高度,比如标题用
min-height或固定行高,即使字体替换,布局骨架不变。
h1, h2 {
font-family: "SiteFont", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.3;
min-height: 1.3em; /* 预留一行,避免替换时塌陷 */
}验证与测量
改完一定要量,不能凭感觉。三个手段:
# 1. 看资源大小和加载顺序(Chrome DevTools → Network,筛 Font)
# 2. 用 Lighthouse 的 "Ensure text remains visible during webfont load" 审计项
# 3. 现场看 CLS:Performance 面板录制,勾选 Layout Shift 区域命令行里快速确认字体文件大小是否达标:
ls -lh /var/www/site/fonts/*.woff2
# 单个分片建议 < 100KB,标题子集建议 < 50KB常见问题排查
1. 字体装了但完全不生效,还是默认字体
依次排查:路径 404(用 curl -I 看)、CORS 被拦(控制台有 cross-origin 报错)、font-family 名字对不上(@font-face 里声明的名字和引用处必须完全一致)、以及 CSS 里被后面的 font-family 覆盖。
2. preload 之后字体反而下载了两次
99% 是漏了 crossorigin。preload 的请求和 CSS 触发的是两个"凭据模式"不同的请求,浏览器认为它们不是同一个资源。
3. 中文网页偶尔出现"方框"或乱码
子集化砍太狠,把某些冷门字砍掉了。要么扩大 unicode-range,要么改用分片方案。上线前用脚本核对一遍:把站内所有文案的字符集和字体覆盖的字符集做差集,差集非空就说明会缺字。
4. 改了 Nginx 缓存头但浏览器还是拿旧的
字体文件已经按旧配置缓存了。改配置时要同时改文件名(加 v2/hash),否则老用户拿不到新文件。这也是"静态资源带 hash"这个习惯的价值所在。
5. 字体优化后 LCP 没改善
字体往往不是 LCP 的决定因素。打开 DevTools 看 LCP 元素到底是什么——如果是一张大图或首屏大标题块,优先优化它。不要为了优化字体而忽略了真正的瓶颈。
小结
字体优化的完整链条其实就五步:能用系统字体就用系统字体 → 品牌字做子集化 / 分片 → 选对 font-display → preload 加 crossorigin → 服务端开长缓存和 CORS,最后用 size-adjust 和行高预留压住 CLS。做完这一套,一个原本上百 KB 甚至上 MB 的字体负担,通常能降到几十 KB,而且首屏文字立刻可见、不跳动。对内容站来说,这意味着更低的跳出率和更好的 Core Web Vitals 评分——这两件事最终都会反映到搜索排名和留存上。字体不是最显眼的优化项,但它是投入产出比最高的那一类:一次配置,长期受益。