#Next.js#React

Web 字体优化:加载、渲染与 CLS

自定义字体让设计更好,但加载不当会拖慢 LCP + 导致 CLS(字体闪烁)。本文讲清字体优化全链路:font-display 策略(swap/fallback/optional)、preload 关键字体、子集化(woff2 + 仅需字符)、FOUT vs FOIT、variable fonts,给一套"字体好看又快"的方案。

本文要点

  • 主题聚焦:Next.js、React
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年2月5日·更新于 2026年7月23日

设计师给你挑了一款优雅的 Inter,或者一套品牌定制的中文衬线字体。上线那天你打开页面,白屏两秒,然后文字"啪"地一跳——从系统宋体换成了你心心念念的品牌字。Lighthouse 分数掉了 15 分,CLS 红了一片,设计却觉得"很好看啊"。

字体优化就是这种尴尬局面:它不影响功能,但它是性能体验里最容易被忽视、又最容易翻车的一环。一张图没加载好,你看得见;一段字体的下载、阻塞、闪烁,你感受到却说不清。

这篇文章把 Web 字体的性能链路从下载到渲染到布局稳定性一次讲透,最后给一套实战可落地的方案。

一、自定义字体的性能代价:好看是有代价的

系统字体(Windows 的微软雅黑、macOS 的 PingFang SC、Android 的 Roboto)是"零成本"的——系统已经装好,渲染立等可取。而自定义字体(Web Font)要付出三重代价:

1. 下载成本。字体是文件,要走网络。一个完整字重的 latin woff2 大概 30–100KB,看着不大;但中文字体动辄几 MB——思源黑体完整全集 woff2 压缩后仍有 5MB+,这比你的整个 JS bundle 还重。

2. 渲染阻塞成本。浏览器遇到一段文字时,要决定"用什么字体画它"。如果 CSS 声明了 Web Font 但字体还没下载完,浏览器会进入一个两难:是先空着等字体(FOIT),还是先用系统字体顶上(FOUT)?默认行为下,大多数浏览器会把文字藏起来最长 3 秒,这段时间里文字是不可见的。

3. 字体闪烁成本(FOUT / FOIT)。等字体终于到了,文字要"切换"。两种字体度量(x-height、字宽、行高)不一样,切换的一瞬间,整段文字的宽度会变,如果这段文字在主内容流里,后面的元素全部跟着位移——CLS 就这么来了。

所以你看,LCP 和 CLS 这两个 Core Web Vitals 指标,字体都能直接拖垮。LCP 元素如果是一行标题文字,字体下载 3 秒就等于 LCP 3 秒;字体切换导致布局位移,直接贡献 CLS。

别误会,不是说不能用自定义字体——品牌识别、可读性、设计一致性都需要它。而是说用,就按规矩用。

二、FOUT vs FOIT:两种字体闪烁,你只能选一种

先把两个术语钉死:

  • FOIT(Flash of Invisible Text,不可见文字闪烁):字体加载期间,文字直接不显示(浏览器渲染成一个透明的占位)。用户看到的是"空白"。
  • FOUT(Flash of Unstyled Text,无样式文字闪烁):字体加载期间,先用系统字体把文字显示出来,等 Web Font 到了再替换。用户看到的是"系统字 → 品牌字"的切换。

两者没有完美的那个,只有取舍。

FOIT 的问题:文字不可见 = 用户白等。如果 LCP 元素是文字,这段时间直接算进 LCP。慢网络下用户盯着空白,以为页面挂了。最恶劣的是"3 秒超时后字体才到"的边缘场景——用户等了 3 秒看到系统字,下一秒又换了,等于既付了 FOIT 的代价又付了 FOUT 的代价。

FOUT 的问题:文字一直在,但会"跳"。切换瞬间如果字体度量不一致,布局位移,CLS 上涨。视觉上也有"换了件衣服"的不稳定感,设计师不喜欢。

结论是清晰的:对性能而言,FOUT 几乎总是优于 FOIT。用户先看到内容,永远好过盯着空白。Google 自己的性能团队、web.dev 的所有指南,都默认推 FOUT。FOIT 唯一合理的场景是"字体差异极大,系统字会破坏品牌识别"的极少数品牌页(比如可口可乐那种 logo 级文字)——而且那种情况你更应该把它做成 SVG/图片。

控制 FOUT/FOIT 的开关,就是下一节的 font-display。

三、font-display 策略:swap / fallback / optional 怎么选

font-display 是写在 @font-face 里的一个描述符,它告诉浏览器:在这段字体下载期间,怎么处理文字显示。它有两个时间窗口——阻塞期(block period,文字不可见) 和 交换期(swap period,显示后备字体,到了就换)。不同取值就是不同的两个窗口长度组合。

css
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-weight: 400;
  font-display: swap; /* 关键这一行 */
}

五个取值,真正常用的三个记牢:

swap(最常用):极短的阻塞期(~100ms)+ 无限的交换期。等于强制 FOUT——文字几乎立刻用系统字体显示,Web Font 什么时候到什么时候换,哪怕 10 秒后到也换。绝大多数项目选这个。代价:慢网络下切换晚,CLS 可能明显。

fallback(最平衡):极短阻塞期(~100ms)+ 短交换期(~3 秒)。意思是"先用系统字顶 100ms,如果 3 秒内字体到了就换,3 秒还没到就放弃,永远用系统字"。它给字体一个合理的窗口,又不会让用户在 10 秒后还经历一次跳变。适合正文——你想要品牌字,但不想为它牺牲稳定性。

optional(最克制):极短阻塞期(~100ms)+ 零交换期。意思是"给字体 100ms,到不了就彻底放弃,这一页永远用系统字"。但它会继续下载并缓存字体——也就是说,首次访问的用户(慢网络)看到系统字、无任何闪烁;再次访问时字体已缓存,100ms 内就能用上,于是看到了品牌字。这是真正的"渐进增强":回访用户得字体,首访用户得稳定,谁都不得罪。

block 和 auto 基本别用——block 是"强制 FOIT 3 秒",auto 是"让浏览器决定",而大多数浏览器默认就是 FOIT,等于把最差选项交了出去。

怎么选?给一套决策:

场景font-display理由
标题 / Hero / 品牌swap标题文字是 LCP 候选,必须立刻可见,字体到了再换
正文 / 长文fallback不要让晚到的字体打断阅读
装饰 / 非关键optional锦上添花的东西,绝不能影响体验
一切情况不要 block / autoFOIT 是性能杀手

四、preload 关键字体:别让字体排队

浏览器发现"这个 @font-face 需要一个 woff2",通常要等到 CSS 解析完、再遇到用到该字体的文字,才会发起请求。这条请求链是串行的:HTML → CSS → 字体。如果首屏关键字体能更早被发现,就能省下一截等待。

<link rel="preload"> 就是干这个的。它告诉浏览器:"这个资源待会儿肯定要用,你现在就去下载,别等。"

html
<link
  rel="preload"
  href="/fonts/inter-var-latin.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>

三个坑必须强调:

1. crossorigin 不能漏。字体资源默认以匿名 CORS(anonymous)模式请求,而 preload 如果不加 crossorigin,预加载和实际请求会因 CORS 策略不匹配,导致浏览器把预加载的结果丢掉、重新请求一遍——等于白 preload。这是我见过最隐蔽也最常见的字体优化"假动作"。

2. 只 preload 首屏真正用到的字体。不要把整个字体家族所有字重都 preload——那是反优化,在和首屏抢带宽。规则:preload 一份 latin 正文字重(最多再加一个粗体),其他延迟加载。

3. preload 和 font-display: optional 不要无脑组合。optional 的语义是"来不了就算了",你却 preload 了它,等于强行说"一定要来",语义打架。preload 配 swap / fallback 才是合理搭配。

配 preload 之后,还要保证 @font-face 里的 src 路径和 preload 的 href 完全一致(含大小写、query string),否则浏览器识别为两个资源,preload 失效。

五、子集化:中文字体优化的命门

如果说前面几节是"latin 字体的小优化",子集化(subsetting)就是中文站点的生死线。

原理很简单:一套字体文件里包含了几千上万个字形(glyph),但你一个页面真正用到的可能只有几百个。把没用到的字形剔出去,文件就小了。这就是子集化。

数字感受一下:

  • 思源黑体 SC 完整全集:~5MB(woff2 后)
  • 仅保留拉丁 + 数字 + 标点:~80KB
  • 仅保留常用 3500 汉字 + 拉丁:~400–600KB
  • 仅保留某一篇文章实际用到的字:几十 KB

一个 5MB 的字体走 4G,要好几秒;子集到几百 KB,1 秒内就到了。差距是数量级的。

工具链:

  • pyftsubset(fonttools 出品):命令行子集化的事实标准。可以按 --unicodes、--text、--text-file 指定要保留的字符,还能同时做 woff2 压缩。
bash
# 用一段文本子集化 + 压成 woff2
pyftsubset SourceHanSansSC.otf \
  --text-file=article-chars.txt \
  --flavor=woff2 \
  --output-file=subset.woff2 \
  --layout-features='*' --no-hinting
  • glyphhanger:基于 Puppeteer,抓取你页面上实际出现的字符,再调 pyftsubset。适合动态内容。
  • subfont:Perf-budget 版,自动分析 HTML/CSS,替换为子集字体。

中文场景的实战策略,我推荐三选一或组合用:

策略 A:用系统字体,不引入 Web Font(最稳)。给一个精心调过的 font-family 栈,在各平台都用上原生中文字体,零下载:

css
body {
  font-family:
    'Inter', system-ui,
    -apple-system, 'PingFang SC',     /* macOS / iOS */
    'Microsoft YaHei',               /* Windows */
    'Noto Sans CJK SC',              /* Linux / Android */
    sans-serif;
}

绝大多数中文内容站,这个方案就够了,而且性能是天花板——零字体请求、零闪烁。

策略 B:正文子集化常用字,品牌字单独引入。把思源黑体子集到常用 3500 字(覆盖绝大多数正文),作为正文 Web Font;标题或 logo 用更激进的子集(只保留出现过的字)。

策略 C:动态子集化(按页面内容)。构建时扫描每篇文章实际用到的汉字,为每篇文章生成一个专属子集字体。这是极致但工程量大的方案,适合内容量大的资讯/博客站点。也可以学 Google Fonts 对 CJK 的处理:把字体按 unicode-range 切成 100 多个分片,浏览器只下载首屏需要的那几片。

一个常见误区:用 next/font/google 加载 Noto Sans SC 之类的 CJK 字体,以为它"自动子集化"了——其实 next/font 的自动子集化只对 latin 生效,中文字体会被原样拉下来,几 MB 照样进首屏。CJK 字体必须自己子集化后用 next/font/local 自托管(见第八节)。

六、Variable Fonts:一个文件装下所有字重

传统字体:一个字重一个文件。Regular、Medium、Bold 三个字重 = 三个 woff2 = 三次请求、三份存储。

可变字体(Variable Fonts,OpenType 1.8 引入):一个文件,内置一条或多条设计轴(axis),最常见的是字重轴(weight, wght)。CSS 想用哪个字重,动态指定就行,字体文件里用插值算出来。

好处:

  1. 请求变少:一个文件覆盖 100–900 全字重区间,不用再为每个字重发请求。
  2. 字重自由:可以用 font-weight: 450 这种"中间值",设计精度大幅提升,以前只有静态字重可选时做不到。
  3. 总字节数更低:一个 variable woff2 通常比"4 个静态字重的总和"小 30%–50%。

用法:

css
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter-var.woff2') format('woff2-variations');
  font-weight: 100 900; /* 关键:声明可变字重区间 */
  font-display: swap;
}

body { font-weight: 400; }
h1 { font-weight: 720; }   /* 任意中间值都行 */
.eyebrow { font-variation-settings: 'wght' 550; }

font-weight: 100 900 这种区间写法是告诉浏览器"这个字体支持 100 到 900 之间的任意字重"。然后用 font-weight: 720 就能拿到插值后的字重,不需要再为 700 单独存一份。

坑也有:

  • 不能盲目认为 variable 一定更小。如果你的页面只用 Regular 和 Bold 两个字重,两个静态 woff2 加起来可能比一个 variable 还小(因为 variable 文件内置了所有中间帧的轮廓数据)。判断标准:页面用到 3 个及以上字重,variable 更划算。
  • 老浏览器兼容。Variable Fonts 在 IE 和很老的 Safari 上不识别 format('woff2-variations'),需要 fallback 到静态字体。但 2026 年了,这个基本可以不管。
  • 子集化工具要支持 variable。pyftsubset 用 --with-zsope 之类选项保留 variable 表,别把 axis 给 subset 掉了。

对中文,variable 的价值要打折——因为中文字体本身的体积瓶颈在字形数量,不在字重数量。先解决子集化,再谈 variable。

七、消除字体切换的 CLS:size-adjust 与 fallback 度量

这是字体优化里最容易被忽略、却最致命的一环。

回到前面那个场景:你用 font-display: swap,文字先用系统字体显示,Web Font 到了再换。问题在于——两种字体的字符宽度、x-height、行高都不一样。切换的那一帧,整段文字的总宽度变化,后面的元素跟着位移,CLS 上去了。

这就是为什么很多人配了 font-display: swap 之后,Lighthouse 的 CLS 反而红了。

修复思路:给 fallback 字体人为"调整度量",让它占据和 Web Font 一样大的空间。CSS 提供 size-adjust、ascent-override、descent-override、line-gap-override 这一组描述符,专门干这个:

css
/* 主字体 */
@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
}

/* 调整过度量的 fallback,占据和 Inter 一样的空间 */
@font-face {
  font-family: 'Inter-fallback';
  src: local('Arial');
  size-adjust: 100.06%;
  ascent-override: 90%;
  descent-override: 22.32%;
  line-gap-override: 0%;
}

body {
  font-family: 'Inter', 'Inter-fallback', system-ui, sans-serif;
}

原理:当 Inter 还没到时,文字用 Inter-fallback(实际是 Arial)渲染,但 size-adjust 把它的字符盒子缩放成和 Inter 几乎一样大。Inter 到了之后,文字换成 Inter,盒子尺寸几乎不变——布局位移被压到肉眼不可见的程度。

难点是这几个百分比怎么算。靠手调不现实,真实工程里靠工具:

  • fontpie:CLI 工具,喂给它字体文件,自动算出 override 值。
  • fontaine:Vite/Next 生态的插件,扫描你用的字体,自动生成 fallback @font-face,零配置。
  • @fontsource 的 fallback:部分字体源已经带了 fallback metrics。

最关键的一点:这套度量匹配只对 latin 系文字效果显著。中文由于字符是等宽方块、且度量差异主要在行高,通常用 line-height 固定 + 子集化到位即可缓解。中文字体切换的 CLS,主要还是靠"子集小、加载快、切换早"(切换越早,位移越容易被后续内容流自然吸收)来治。

八、Next.js next/font:字体优化的"全自动挡"

如果你用 Next.js,前面七节的大部分手工活,next/font 都替你做了。它是目前 React 生态里最省心的字体方案。

next/font 做对了五件事:

1. 自托管,消除第三方请求。next/font/google 在构建时把 Google Fonts 下载下来,作为静态资源放到你的域名下。线上没有 fonts.googleapis.com 这条请求——既快(无跨域、可走你的 CDN),又合规(GDPR 友好,不向 Google 泄露访客 IP)。

2. 自动 preload 关键字重。用到的字体会自动加上 <link rel="preload">,且带正确的 crossorigin。

3. 自动子集化(latin)。默认只打包 latin 子集,体积控制到几十 KB。

4. 自动配 font-display。默认 swap,可改。

5. 自动消除 CLS(最关键)。next/font 内部就是用上一节那套 size-adjust / ascent-override 技巧,为你的字体预生成匹配度量的 fallback。这就是官方说的"no layout shift"的来源——你什么都不用配,字体切换就不会引起 CLS。

基本用法(Google 字体):

tsx
// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter', // 暴露成 CSS 变量
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}
css
/* 在任意 CSS 里用 */
body {
  font-family: var(--font-inter), system-ui, sans-serif;
}

自托管本地字体(用于子集化后的中文字体):

tsx
import localFont from 'next/font/local';

const notoSc = localFont({
  src: './fonts/noto-sc-subset.woff2', // 你自己子集化好的文件
  display: 'swap',
  variable: '--font-zh',
  fallback: ['system-ui', 'PingFang SC', 'Microsoft YaHei'],
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN" className={notoSc.variable}>
      <body>{children}</body>
    </html>
  );
}

中文字体的推荐姿势就藏在上面这段:不要用 next/font/google 加载 Noto Sans SC(它不会帮你子集化,几 MB 直接进首屏),而是自己子集化好一个 woff2,用 next/font/local 自托管,享受 next/font 的 preload + CLS 消除红利。

一句话:next/font 是默认正确选项。除非你有特殊原因(不用 Next、或字体源不在 Google Fonts 且不想本地托管),否则别手写 @font-face。

小结

  • 自定义字体的三重代价:下载、渲染阻塞、切换闪烁。它同时影响 LCP(文字晚可见)和 CLS(切换位移)。系统字体是零成本天花板,引入 Web Font 必须按规矩来。
  • font-display 是核心开关:swap(立刻显示、无限换)给标题,fallback(3 秒内换、超时放弃)给正文,optional(首访不换、回访换)给装饰。永远不要用 block / auto 制造 FOIT。
  • 子集化是中文字体的命门:全集几 MB,子集几百 KB,数量级差距。CJK 别指望任何框架"自动子集",自己用 pyftsubset / glyphhanger 切好,再用 next/font/local 自托管。
  • preload 只 preload 首屏关键字体,且必须带 crossorigin,否则等于白做。
  • 消除 CLS 要靠 fallback 度量匹配:size-adjust + ascent-override 那一套,或者直接用 next/font / fontaine 自动生成。配了 swap 不配这个,CLS 一定红。

一句话收尾:字体优化只有两条铁律——让该看见的字尽快看见,让不该跳的布局别跳。 做到这两条,设计稿的好看就能不交性能税地落到用户屏幕上。

Related

相关推荐