字体优化完全指南(2026):用 variable fonts、size-adjust 与 font-display 消灭 FOUT 和 CLS

从 30 多次电商审计的真实数据出发,讲清楚 2026 年字体优化的每一步:可变字体节省 40-70% 体积、preload 提前 300ms、size-adjust 把 CLS 降到 0.001,CJK unicode-range 分片实战,Next.js 15 next/font 三行搞定自托管。

字体优化完全指南(2026):Variable Fonts + CLS

更新时间:2026年9月19日

字体优化是指通过可变字体(variable fonts)、font-display、preload、size-adjust 与自托管 woff2 等手段,把 Web 字体的下载、渲染与布局代价降到最低,从而消除 FOUT、FOIT 与 CLS,并把 LCP 提前 200-600ms。老实讲,在我给 Series B/C 电商做的 30 多次审计里,字体几乎永远是首屏最容易被低估的性能税。一个 180KB 的 Latin+CJK 字体请求,就能让 P75 LCP 从 1.8s 掉到 3.2s。这篇 2026 版指南把我在 Shopify Storefront Performance 团队和独立咨询里跑通的所有字体优化手段一次讲清楚。

  • 可变字体(variable fonts)用一个 woff2 文件替代 6-9 个静态字重,通常能减少 40-70% 的字体总体积。
  • font-display: swap 消除 FOIT 但会引入 FOUT。optional 在慢网络上可以完全避免 CLS,代价是首访用户看不到品牌字体。
  • size-adjust、ascent-override、descent-override 让 fallback 字体的字形盒子精确匹配 Web 字体,把字体切换造成的 CLS 降到 0.001 以下。
  • 自托管 woff2 加 preload 加 Cache-Control: immutable 比 Google Fonts CDN 更快,也避免了第三方连接开销。
  • 对 CJK 字体(中日韩)永远要做 unicode-range 分片或按需子集化,一整套 CJK 字体 5-15MB 是首屏杀手。
  • 用 Fontaine、Fontsource 或 Capsize 等工具自动生成 metric override,比手写 CSS 更可靠。

Web 字体到底吃掉了多少性能预算

先给一个我最近做的真实案例。一家北美 DTC 品牌,桌面 P75 LCP 3.4s,首屏加载了 Inter Regular、Medium、SemiBold、Bold 四个字重的 Latin 字体,再加一个中文 Noto Sans SC 的 Regular 和 Bold。字体请求总计 1.9MB,其中 CJK 字体占 1.6MB。字体是在 HTML 解析完 <head> 之后才被 CSSOM 触发下载,等 CSS 里 font-family: Inter 出现时已经错过了浏览器的初始网络窗口。结果就是 LCP 元素(一个大标题)在 2.8s 才拿到正确字形。

只做三件事,切成 variable font、加 preload、给 fallback 加 size-adjust,LCP 降到 1.9s,CLS 从 0.14 降到 0.02。字体是我在 LCP 优化完全指南里反复强调的三大杠杆之一,比 preconnect、fetchpriority 都更容易拿分。

字体的性能成本可以拆成四块:字节体积(网络传输时间)、发现时机(浏览器什么时候知道要下它)、渲染阻塞(拿到之前文本是否可见)、布局位移(切换前后字形宽高变化)。四块每一块都有对应的优化手段,接下来一一拆解。

可变字体:一个文件替代九个字重

可变字体(variable fonts,OpenType 1.8+)把多个字重、字宽、字体风格编码到一个 woff2 文件里,通过 fvar 表暴露可调整的轴(axes)。最常用的轴是 wght(100-900)、wdth(宽度)、ital(斜体),字体设计师还可以定义自定义轴(比如 Recursive 有 MONO 轴)。

为什么值得用?我拿 Inter 举例。静态 Inter 六个字重(Regular/Medium/SemiBold/Bold/ExtraBold/Black)加起来 woff2 大约 128KB;InterVariable.woff2 一个文件 73KB,还额外送了 100-900 之间任意值和斜体轴。省了 43%,而且再加字重不要钱。

/* 声明可变字体,用 font-weight 范围表明 wght 轴 */
@font-face {
  font-family: "Inter";
  src: url("/fonts/InterVariable.woff2") format("woff2-variations");
  font-weight: 100 900;   /* wght 轴范围 */
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Inter";
  src: url("/fonts/InterVariable-Italic.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-style: italic;
  font-display: swap;
}

/* 使用时任意 wght 值都合法 */
h1 { font-family: "Inter", system-ui, sans-serif; font-weight: 720; }
h2 { font-family: "Inter", system-ui, sans-serif; font-weight: 640; }
p  { font-family: "Inter", system-ui, sans-serif; font-weight: 400; }

需要注意的是,format("woff2-variations") 现在是标准语法。Safari 15 之前的旧版本只认 format("woff2"),但 Safari 15 已经是 2021 年发布,2026 年基本可以只写 variations。可变字体在所有主流浏览器已 100% 支持(caniuse: variable-fonts)。

有一个坑值得提。不是所有字体都提供 variable 版本,而且很多字体的 wght 轴不覆盖全部权重。买字体前一定看清 fvar 表,用 fonttools 里的 ttx 命令可以查:ttx -t fvar -o - InterVariable.woff2。

font-display 五个值:怎么选

font-display 决定字体没下载完时浏览器怎么处理文本。它有五个值:auto、block、swap、fallback、optional。区别在于 block period(隐藏文本时长,展示 FOIT)与 swap period(允许切换字体时长)。

值Block 时长Swap 时长典型效果适用场景
auto浏览器决定(通常 3s FOIT)无限长时间 FOIT,慢网络最糟不推荐
block~3s无限FOIT 3 秒后 FOUTIcon fonts、Logo
swap0无限立即 fallback,字体到后 FOUT大多数正文
fallback100ms3s短 FOIT + 有限 FOUT次要文本
optional100ms0慢网络首访直接不用 Web 字体追求 0 CLS

我的默认选择是 swap 加 size-adjust(下一节讲)。这样首屏立刻可读,字体到达后切换,但因为 fallback 和 Web 字体度量已经对齐,肉眼几乎看不出跳动。optional 更激进,也更适合 P90 网络很慢的地区(比如东南亚 3G)。代价是首访用户看不到品牌字体,但第二次访问命中缓存后就一直有了。

block 我只在字形错误比字体延迟更致命时才用,比如整个 UI 都靠 icon font(虽然 2026 年更推荐 SVG sprite)。参考 MDN font-display 规范。

FOUT 和 FOIT 到底是什么

FOIT(Flash of Invisible Text):字体加载期间文本完全不可见,用户看到空白。FOUT(Flash of Unstyled Text):文本先用 fallback 字体显示,字体到达后突然切换。swap 消除 FOIT 但制造 FOUT;optional 通过放弃 Web 字体两者都消除。size-adjust 能让 FOUT 几乎无感,这是 2026 年最推荐的组合。

如何用 preload 把字体提前 300ms 送达

默认情况下浏览器要等 CSSOM 构建完,然后发现某个规则用了某个 font-family,并且这个 font-family 被文档里实际使用了,才会去下载字体。这个链路通常比 HTML 到达晚 200-800ms。<link rel="preload" as="font"> 直接把字体请求提到 HTML 解析早期。

<!-- 在 <head> 里,越早越好 -->
<link
  rel="preload"
  href="/fonts/InterVariable.woff2"
  as="font"
  type="font/woff2"
  crossorigin
  fetchpriority="high"
>

三个必须注意的点:

  1. 必须写 crossorigin:即使字体是同源的,字体请求默认按 CORS 模式发起。preload 不加 crossorigin 会导致浏览器再下一次(我见过因为这个字体下两次的线上事故)。
  2. 必须写 type="font/woff2":不写会触发不必要的 Content-Type 检查。
  3. 只 preload 首屏用到的字体:过度 preload 会挤占其他关键资源的带宽。一般 preload 1-2 个字重,其他懒加载。

2026 年 Chrome 已经把 fetchpriority="high" 从实验特性提升为默认支持,这个属性告诉浏览器字体的优先级高于其他 preload。搭配 Early Hints (103) 甚至可以在 HTML 到达前就开始下字体。我在电商首页上实测能把 LCP 再压 150-250ms。

用 size-adjust 消除字体切换的 CLS

2026 年字体优化最重要的进步是 @font-face 里的四个 metric override 描述符:size-adjust、ascent-override、descent-override、line-gap-override。这些属性让你把 fallback 字体(比如 Arial)的字形盒子"伪装"成 Web 字体(比如 Inter)的大小,字体切换时肉眼几乎察觉不到。

核心思想:给 fallback 字体单独声明一个 @font-face,用 override 描述符调整它,然后在 font-family 里把这个 fallback 排在 Web 字体后面。

/* Web 字体 */
@font-face {
  font-family: "Inter";
  src: url("/fonts/InterVariable.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
}

/* 调整后的 fallback,专门给 Inter 用 */
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107.4%;
  ascent-override: 90%;
  descent-override: 22.4%;
  line-gap-override: 0%;
}

/* 在 stack 里同时使用两者 */
body {
  font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}

数字怎么算?可以手算:把 Web 字体的 unitsPerEm、ascent、descent 除以 fallback 的对应值。但更靠谱的做法是用工具。Fontaine(Nuxt 官方)和 Capsize(Seek 出品)都能自动生成这些数字。我用 Fontaine 给客户做过 20 多个项目,字体切换 CLS 一律降到 0.001 以下。

这套技巧配合我在 CLS 优化完全指南里讲的 aspect-ratio 和 CSS Containment,能把整站 CLS 稳定压到 0.05 以下。Chrome DevTools 的 Performance Insights 面板 2026 版会直接标出"font swap CLS",非常方便定位。

ex 单位与 x-height 匹配

有时你只想让某一段文本用 fallback 时高度不跳动,可以用 ex(x-height)单位配合。但 size-adjust 已经内部用 x-height 做归一化,一般不需要额外的 ex 工程。

自托管字体比 Google Fonts CDN 更快吗

2026 年的答案是几乎永远是。原因有三个:

  1. 省一次 DNS + TCP + TLS:Google Fonts 用 fonts.googleapis.com(CSS)和 fonts.gstatic.com(字体文件)两个域名,即使 preconnect 也要建两次连接。同源字体是零成本。
  2. 缓存独占:Chrome 86 之后跨域缓存被分区(Cache Partitioning),不同网站访问 Google Fonts 的同一个字体也不会共享缓存。"用 CDN 是因为大家都能命中缓存"的老理由 2020 年就失效了。参考 Chrome HTTP Cache Partitioning 公告。
  3. 可以 preload:Google Fonts 的字体 URL 带 hash,会周期变化,无法可靠 preload。自托管 URL 稳定,可以搭配 Cache-Control: public, max-age=31536000, immutable。

推荐工具:Fontsource 把 Google Fonts 目录里的字体全部打包成 npm,包含 variable 版本,可以直接 import "@fontsource-variable/inter";google-webfonts-helper 提供在线下载 + CSS 生成。

什么情况下 Google Fonts CDN 更好?只有一种:你只用 1 个字体、1 个字重,且你的服务器在离 CDN edge 很远的机房。这种边缘案例现在极少。参考 web.dev Font best practices。

CJK 字体的 unicode-range 分片实战

中文、日文、韩文字体是性能大户。一套完整的 Noto Sans SC Regular 是 4.5MB woff2,加上其他字重可以到 15MB。这个体积对首屏不可接受。解决办法是 unicode-range 分片。

unicode-range 让你把一个字体拆成多个 @font-face 段(每段对应一段 Unicode 码位),浏览器只下载页面实际用到的段。

/* 常用汉字:GB2312 一级 3755 字,woff2 约 380KB */
@font-face {
  font-family: "Noto Sans SC";
  src: url("/fonts/NotoSansSC-common.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
  unicode-range: U+4E00-9FFF;
}

/* 拉丁 + 数字:woff2 约 12KB */
@font-face {
  font-family: "Noto Sans SC";
  src: url("/fonts/NotoSansSC-latin.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F;
}

/* 生僻字:懒加载,only fetch 需要时 */
@font-face {
  font-family: "Noto Sans SC";
  src: url("/fonts/NotoSansSC-rare.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
  unicode-range: U+3400-4DBF, U+20000-2A6DF;
}

Google Fonts 早就这么做了。它的中文字体 CSS 里有几十个 @font-face 段。自托管时可以用 pyftsubset(fonttools 包)分片,或者用 glyphhanger 自动扫描站点用到的字符集。我给中文电商客户的默认配置是:常用汉字加拉丁加标点是首屏必须,生僻字段落按需加载。这样中文字体首屏成本能从 4.5MB 压到 400KB 以内。

自动化:Fontaine、Fontsource 与 Capsize

手写 size-adjust 数字容易出错,而且每次换字体都要重算。2026 年推荐直接用工具链自动化:

  • Fontaine(Vue/Nuxt 生态):在 build 时读取 woff2 文件的 metrics,自动生成 fallback @font-face 规则并注入到 CSS。零配置。
  • Fontsource:npm 上的开箱即用字体包,每个字体自带 preload 建议和 override CSS,直接 import "@fontsource-variable/inter/index.css"。
  • Capsize:Seek 开源,提供 createFontStack() API,输入 Web 字体加 fallback 列表,输出完整 CSS。适合 React/Next.js。
  • Fontpie / fontaine-cli:命令行版本,适合非框架项目。
  • Next.js next/font:Next 13+ 内置的 next/font/google 和 next/font/local 会自动生成 override,且默认自托管。

下面是 Next.js 15 的例子,3 行代码就搞定了自托管加 preload 加 size-adjust:

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

const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
  display: "swap",
  adjustFontFallback: true,  // 自动 size-adjust,默认开
  preload: true,
});

export default function RootLayout({ children }) {
  return (
    <html lang="zh" className={inter.variable}>
      <body style={{ fontFamily: "var(--font-inter), sans-serif" }}>{children}</body>
    </html>
  );
}

我把这一套推荐给几乎所有客户,只有需要精细控制 unicode-range 分片时才手写 CSS。参考 Next.js next/font 官方文档。

Lighthouse 与 WebPageTest 字体审计清单

我的字体审计 checklist,跑过 200 多次的沉淀,直接抄走:

  1. Lighthouse Performance:看"Ensure text remains visible during webfont load"(对应 font-display)和"Preload key requests"(对应 preload)。
  2. Chrome DevTools Coverage 面板:查字体 CSS 有多少未使用规则,识别应该拆分的 @font-face。
  3. Chrome DevTools Network 面板:filter=font,看每个字体的时序、体积、Priority、是否重复请求。
  4. WebPageTest Filmstrip:慢速 4G 下看 LCP 帧字体是否已经切换,还是 FOUT。
  5. PageSpeed Insights CrUX 数据:看 P75 CLS 是否有一段"font-related jitter"。
  6. Lighthouse CI 里加 font-display 断言:把这条并入 PR gate,避免团队里有人偷偷加没有 font-display 的字体。我在 第三方脚本性能优化指南里介绍过 Lighthouse CI 的完整配置模式,同样适用。
  7. Real User Monitoring:用 document.fonts.ready 加时间戳,测每类字体在真实用户中的加载完成时间。
// 简单的字体加载 RUM 上报
if (document.fonts && document.fonts.ready) {
  const start = performance.now();
  document.fonts.ready.then(() => {
    const duration = performance.now() - start;
    navigator.sendBeacon("/rum/fonts", JSON.stringify({
      duration,
      count: document.fonts.size,
      connection: navigator.connection?.effectiveType,
    }));
  });
}

做完这些,你的字体基本上就不会再是性能瓶颈。剩下的 LCP 空间要去攻服务端 TTFB 和图片。

常见问题

如何减少字体加载导致的 CLS?

用 @font-face 里的 size-adjust、ascent-override、descent-override 让 fallback 字体的字形盒子精确匹配 Web 字体,字体切换时不会引起布局位移。推荐用 Fontaine、Capsize 或 Next.js 的 next/font 自动生成这些数值。

font-display swap 和 optional 有什么区别?

swap 立即用 fallback 显示文本,字体到达后无限期切换,会产生 FOUT。optional 只给 100ms 的下载窗口,超时后直接放弃使用 Web 字体,慢网络首访用户看不到品牌字体但完全避免 CLS,第二次访问命中缓存后正常显示。

自托管字体比 Google Fonts CDN 更快吗?

2026 年几乎永远更快。原因:省一次 DNS+TCP+TLS 连接、跨域缓存自 Chrome 86 起已分区("共享缓存"不再存在)、可以稳定 preload、可以设 immutable 缓存头。推荐用 Fontsource npm 包直接自托管。

什么是可变字体(variable font),值得用吗?

可变字体是 OpenType 1.8 引入的格式,一个 woff2 文件内包含多个字重、字宽、字体风格的连续变化轴。相比多个静态字重通常能省 40-70% 体积,且支持无级字重。所有主流浏览器都已支持,2026 年是默认推荐。

中文字体太大怎么办?

用 unicode-range 把字体按码位分片:常用汉字段(U+4E00-9FFF)加拉丁段加标点段是首屏必须,生僻字段按需加载。可用 pyftsubset 或 glyphhanger 生成子集。对 UGC 站点可用 Cloudflare Workers 服务端实时子集化。

preload 字体为什么要写 crossorigin?

字体请求默认按 CORS 模式发起,即使字体是同源的。<link rel="preload"> 不加 crossorigin 时浏览器会认为 preload 的资源和实际使用的是两种不同的请求模式,导致字体被下载两次。这个坑很常见,DevTools Network 面板可以核对。

关于作者 Priya Ravindran

Priya is a frontend performance engineer with 11 years of experience untangling slow React and Next.js apps. She spent four years at Shopify on the Storefront Performance team, where she drove a project that cut median LCP across the Online Store theme platform from 3.4s to 1.8s by rewriting the critical render path and killing third-party tag bloat. Before that she shipped checkout perf work at Klarna. These days she runs an independent practice helping Series B/C ecommerce companies hit a 75th-percentile INP under 200ms before they bother with redesigns. She has a soft spot for Lighthouse CI in pull-request gates and writes most of her perf budgets in YAML. Outside work she's slowly restoring a 1998 Honda CB400 and arguing with her partner about whether RUM beats lab data (it does).