前言

想写这篇文章已经很久很久了,最近狠下心来整理这些资料,希望能帮助到大家。

合适的字体格式

字体文件有很多种:ttf、otf、eot、svg、woff、woff2;不同的格式用于不同的使用场景,而且大部分格式其实都不推荐使用,最终最值得使用的就是 woff2 格式。

格式推荐优先级

  1. WOFF2:面向所有现代浏览器,性能最优。
  2. WOFF:面向稍旧一些的浏览器,作为 WOFF2 的主要备选。
  3. TTF/OTF:面向更老旧的移动浏览器。
  4. EOT (可选):仅在需要兼容 IE8 及以下浏览器时才添加。
  5. SVG (基本不用):除非有极端罕见的兼容性需求。

最佳实践代码

一个健壮的@font-face 实践代码:

@font-face {
  font-family: "MyCustomFont";
  /* 仅为兼容 IE9 以下版本 */
  src: url("myfont.eot");

  /* 现代浏览器的标准写法 */
  src: url("myfont.eot?#iefix") format("embedded-opentype"), /* 兼容 IE6-IE8 */
      url("myfont.woff2") format("woff2"), /* 现代浏览器首选 */ 
      url("myfont.woff") format("woff"), /* 主要备选 */
      url("myfont.ttf") format("truetype"), /* 兼容旧版 Safari, Android, iOS */
      url("myfont.svg#MyCustomFont") format("svg"); /* 兼容极旧版 iOS (几乎不用) */

  font-weight: normal;
  font-style: normal;
  font-display: swap; /* 推荐添加,改善用户体验 */
}

对于绝大多数现代项目,你只需要 WOFF2 和 WOFF 两种格式就足够了:

@font-face {
  font-family: "MyCustomFont";
  src: url("myfont.woff2") format("woff2"), url("myfont.woff") format("woff");
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}

表格汇总

格式压缩算法推荐度主要适用场景
WOFF2Brotli★★★★★ (最高)所有现代浏览器,性能首选
WOFFzlib/Flate★★★★☆ (很高)作为 WOFF2 的备选,兼容旧版浏览器 (IE9+)
TTF/OTF无 (原始)★★☆☆☆ (较低)作为 WOFF 的备选,兼容更老的移动端浏览器
EOT微软私有★☆☆☆☆ (极低)仅用于兼容 IE6-IE8
SVG无 (矢量)☆☆☆☆☆ (弃用)已被废弃,仅用于兼容 iOS 4.1-

优化字体加载策略

通过优化字体加载的时机或者场景,可以优化用户体验,但也仅限于此,正所谓“力大砖飞”,只要网速好,这个优化就没啥存在感,但是也是需要了解一下的。

1.预加载字体文件

使用 <link rel="preload"> 提示浏览器提前加载字体文件,减少渲染阻塞和等待时间。

<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin />

2.font-display 属性

通过这个来控制字体的渲染行为,它常用于解决自定义字体导致的 不可见文本闪烁” (Flash of Invisible Text, FOIT) 问题。

在这之前我们需要了解下浏览器加载网页字体的行为:

  1. 浏览器开始渲染页面,发现需要一个自定义字体(通过 @font-face 指定)。
  2. 浏览器开始下载这个字体文件。
  3. 在字体文件完全下载并准备好之前,使用了该字体的文本将是不可见的
  4. 字体下载完成后,文本突然出现。

这个过程导致了一个糟糕的用户体验,被称为 “不可见文本闪烁” (Flash of Invisible Text, FOIT)。用户会看到一块空白区域,直到字体加载完毕,这会让页面感觉很慢,甚至用户会以为页面坏了。

font-display 就是为了解决这个问题而诞生的。它允许开发者 控制字体在下载期间的显示策略,告诉浏览器在自定义字体还不可用时应该怎么做。

用法:

@font-face {
  font-family: "MyCustomFont";
  src: url("myfont.woff2") format("woff2");
  /* 在这里使用 font-display,swap表示先用能显示的字体显示文本,等自定义字体加载完毕再替换过来 */
  font-display: swap;
}

font-display 的五个值及其行为:

为了更好地理解这五个值,我们可以想象一个 “字体显示时间线”,它分为三个阶段:

  1. 字体阻塞期 (Block Period):如果字体没加载出来,浏览器会渲染不可见的占位文本。在此期间,浏览器等待字体。
  2. 字体交换期 (Swap Period):如果在阻塞期后字体仍未加载出来,浏览器会显示后备字体 (Fallback Font)。在此期间,浏览器仍然在等待字体,一旦加载成功,就会交换成自定义字体。
  3. 字体失败期 (Failure Period):如果字体最终加载失败,或者交换期结束后仍未加载成功,浏览器将永久使用后备字体

下面是 font-display 五个值的详细解释:

阻塞期 (Block)交换期 (Swap)行为描述
auto由浏览器决定由浏览器决定默认值。浏览器的行为通常和 block 类似。
block较短 (约 3s)无限长 (∞)先隐藏,再替换。给字体一个短暂的阻塞期,如果没加载出来就显示后备字体,但会一直等待自定义字体,一旦加载成功就立即替换。会导致 FOIT
swap极短 (0-100ms)无限长 (∞)先显示,再替换。几乎没有阻塞期,立即显示后备字体。当自定义字体加载成功后,再替换过来。这会导致 FOUT (无样式文本闪烁)
fallback极短 (0-100ms)较短 (约 3s)折中方案。和 swap 一样,先快速显示后备字体。但它只给自定义字体一个很短的交换期,如果在这期间没加载成功,就不再替换,永久使用后备字体。
optional极短 (0-100ms)可选字体。和 fallback 类似,但交换期为零。浏览器会尝试加载字体,如果它没能“立即”(通常指在第一次渲染前)加载成功,就会直接使用后备字体,并且不再尝试下载或替换

字体子集化(Subsetting)

简单来说,字体子集化就是从一个完整的字体文件中,只提取出你实际需要用到的那部分字符,然后生成一个全新的、体积更小的字体文件。

一个生动的比喻:

想象一个完整的字体文件(比如思源黑体,NotoSansSC-Regular.otf)是一个包含了数万种菜肴的超级自助餐厅。

  • 不使用子集化:你为了吃一盘炒饭,把整个餐厅的所有菜都打包带回了家。这显然非常浪费,而且很慢。一个完整的中文字体文件通常有 10-20MB 大小。
  • 使用子集化:你只在你自己的盘子里装上你真正想吃的那几样菜(比如炒饭、宫保鸡丁、西兰花),然后带回家。这既快又高效。

可以理解子集化就是对字体文件做精简,我之前用的比较多的就是字蛛(FontSpider)来做手动的字体子集化。如果只是英文的话,精简完只有十几 K 甚至更小。

其主要使用场景如下:

  1. 固定文本内容(最常见、效果最好),比如文章标题、活动标题、按钮文字等。
  2. 整篇文章或页面的静态内容。
  3. 动态内容(技术要求高),比如用户评论、新闻 feed 流等,内容是动态生成的,你无法预知会用到哪些汉字。

比较好理解的就是 1、2 两点,因为这些内容都是固定不变的,你只需要把字体文件精简到只包含你需要的字符即可。

动态子集化

动态子集化是指 在服务器端根据每次请求的具体文本内容,实时生成一个只包含这些文本所需字符的字体子集,然后将其返回给客户端。

核心流程:

  1. 客户端(浏览器):请求一个页面或数据,比如一篇新闻文章。
  2. 服务器端
    a. 接收到请求,获取到将要显示的文本内容(例如,从数据库中查询出的文章正文)。
    b. 分析文本:提取出文本中所有不重复的字符。
    c. 实时生成字体:调用字体处理库(如 HarfBuzz、fonttools),以一个完整的字体文件为模板,根据提取出的字符列表,实时生成一个极小的 WOFF2 或 WOFF 字体子集。
    d. 返回响应:将页面 HTML 和一个指向这个动态生成字体的 CSS(或直接内联 CSS)一起返回给浏览器。
  3. 客户端(浏览器):收到 HTML 和 CSS 后,根据 CSS 中的 @font-face 规则下载这个定制的、极小的字体文件。

标准化流程是这样的,但实际上还是可以灵活变通,比如统计字符的操作也可以在客户端完成,而不一定需要在服务器端,服务器端可以只负责响应请求,这方面权威的就是 Google Fonts

优点:

  • 极致优化:为每一个动态页面都提供体积最小的字体文件。
  • 灵活性高:完美支持新闻网站、博客、用户生成内容(UGC)社区等内容不可预测的场景。
  • 体验无缝:用户总能以最快的速度看到应用了自定义字体的文本。

缺点:

  • 服务器开销:每次请求都需要进行文本分析和字体生成,会消耗 CPU 资源。
  • 响应延迟 (TTFB):服务器处理时间变长,可能会增加首字节时间(Time to First Byte)。
  • 架构复杂:需要后端服务支持,并且通常需要配合强大的 CDN 缓存策略来缓解服务器压力。

Google Fonts 动态子集化

这里我们以 Google Fonts 谷歌字体为例看看如何使用动态子集化。我们以请求一个包含特定文本的 Noto Sans SC (思源黑体) 字体为例。

1. 请求 URL

我们通常会在 HTML 中这样引入 Google Fonts:

<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
  href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400&display=swap"
  rel="stylesheet"
/>

这只是第一步。Google 还没法知道你需要哪些字符。

当你加上 text 参数时,魔法就开始了:

<link
  href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400&display=swap&text=你好世界"
  rel="stylesheet"
/>

或者,为了 URL 编码安全:

<link
  href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400&display=swap&text=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"
  rel="stylesheet"
/>

2. Google Fonts 服务器的响应

当你访问上面带有 text 参数的 URL 时,Google Fonts 的服务器会返回一段 CSS,而不是字体文件。内容如下:

/*
 * 响应来自:https://fonts.googleapis.com/css2?family=Noto+Sans+SC&text=你好世界
 */

/* latin */
@font-face {
  font-family: "Noto Sans SC";
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/s/notosanssc/v32/k3k9o84fb-sA-VzMWBM3amep_sHq_14.woff2)
    format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

/* chinese-simplified */
@font-face {
  font-family: "Noto Sans SC";
  font-style: normal;
  font-weight: 400;
  /*
     注意这个 URL!它是一个动态生成的、包含了“你、好、世、界”四个字形的字体文件。
     这个 URL 很可能是哈希过的,并且会被 Google 的 CDN 缓存。
  */
  src: url(https://fonts.gstatic.com/s/notosanssc/v32/k3k-o84fb-sA-VzMWBM3amep_sHq_8G-p0c.woff2)
    format("woff2");
  /*
     unicode-range 精确地告诉浏览器,这个字体文件只包含这四个汉字的 Unicode 码点。
     浏览器只有在页面上实际出现这些码点的字符时,才会去下载这个字体文件。
  */
  unicode-range: U+4f60, U+597d, U+5 世界, U+754c;
}

(注:unicode-range 中的 U+5世界 是为了演示,实际应为 U+4e16)

Google Fonts 的实现精髓

  • text 参数:这是触发动态子集化的“开关”。
  • 服务器端处理:服务器接收到 text=你好世界,立刻从完整的 Noto Sans SC 字体库中,提取出这四个汉字的字形数据,生成一个全新的、极小的 woff2 文件。
  • unicode-range 优化:返回的 CSS 中使用了 unicode-range 属性。这是一个强大的优化手段,它告诉浏览器:“我这里有一个字体文件,但它只适用于 Unicode 码点在 U+4f60, U+597d... 范围内的字符”。浏览器会非常智能地只在页面上需要渲染这些字符时,才去下载对应的字体文件。这使得可以为不同字符集(如拉丁文、中文、日文)提供不同的字体文件,实现按需加载。
  • CDN 缓存:对于相同的文本请求(比如很多人都请求了 "首页"),Google 会将生成的子集字体缓存在其全球 CDN 上。下次再有相同的请求,会直接从最近的 CDN 节点返回,速度极快,也大大减轻了源服务器的压力。

如果没有 text 参数呢?

如果你不提供 text 参数,Google Fonts 会返回一个包含数千个常用汉字的通用子集,这个文件依然比完整的字体库小得多(比如 1-2 MB 而不是 20 MB),但远不如动态生成的子集那么极致。

自己手动实现动态子集化

如果你想自己搭建一套这样的服务,通常需要以下几个核心部分:

  1. API 网关 (API Gateway)

    • 职责:接收前端请求,处理认证、限流等。
    • 技术选型:Nginx, Express.js, Spring Boot 等。
  2. 缓存层 (Caching Layer)

    • 职责:存储已生成的字体子集映射关系,是整个系统性能的保障。
    • 技术选型:Redis, Memcached。
  3. 字体子集化 Worker (Font Subsetting Worker)

    • 职责:执行真正的字体裁剪工作,这是计算密集型任务。
    • 技术选型:强烈推荐 Python + fonttoolsfonttools 是目前最强大、最专业的开源字体处理工具。可以通过消息队列(如 RabbitMQ, Kafka)与主应用解耦。
  4. 对象存储 (Object Storage)

    • 职责:永久存放生成的字体文件。
    • 技术选型:AWS S3, Google Cloud Storage, 阿里云 OSS, 或自建 MinIO。
  5. 内容分发网络 (CDN)

    • 职责:在全球范围内加速字体文件的分发,降低延迟。
    • 技术选型:Cloudflare, Akamai, 或云服务商自带的 CDN。

很可惜的是我目前没找到大厂的一些资料和文献,一些相关的内容,比如:《GMTC Bilibili 字体优化》、《Bilibili 主站动态字体与 fallback 实践》、《Iconfont—设计师和工程师的好朋友》这些文章都英文时间的原因,链接都已经失效了,所以大家有兴趣只能自行搜索相关资料。

知识补充 - unicode-range

unicode-range经常出现在字体优化的方案中,上面的子集化也用到了这个属性。简单来说,unicode-range 的作用就像一个 “字体加载的条件触发器”

它在 @font-face 规则中,告诉浏览器:“我定义的这个字体文件(src 指向的文件),只包含特定 Unicode 编码范围内的字符。只有当你需要渲染这个范围内的字符时,才去下载这个字体文件。

核心解决的问题:

避免加载一个包含数万字符的完整字体库,而当前页面可能只需要其中的几十个字符。它允许你将一个大字体分割成多个小块,然后按需加载。

工作原理:

  1. 浏览器解析 CSS,看到多个使用了 相同 font-family 名称不同 unicode-range@font-face 规则。
  2. 浏览器并不立即下载所有这些字体文件。
  3. 当浏览器开始渲染页面内容时,它会检查每个字符的 Unicode 码点。
  4. 如果一个字符的码点命中了某个 @font-face 规则中定义的 unicode-range,浏览器才会去下载该规则 src 指向的那个字体文件。
  5. 一旦下载完成,该字体文件就会被用来渲染所有匹配其 unicode-range 的字符。

unicode-range 的语法:

unicode-range 的值可以是一个或多个用逗号分隔的 Unicode 范围。

  1. 单个码点 (Single Codepoint):只匹配一个字符。
    unicode-range: U+26; 只对 Unicode 字符 & (与号) 生效。
  2. 码点区间 (Range):匹配一个连续的字符范围。
    unicode-range: U+0000-00FF; 匹配所有基础拉丁字符。
  3. 通配符范围 (Wildcard Range):使用 ? 作为通配符,代表任意十六进制数字。
    unicode-range: U+4??; 匹配从 U+400 到 U+4FF 的所有字符。
  4. 组合使用:将多个范围用逗号隔开。
    unicode-range: U+0025-00FF, U+4??, U+2190-2199;

实践案例:

unicode-range 的威力只有在与 字体子集化 结合时才能完全体现。下面是几个从简单到高级的实践案例。

案例 1:分离基础西文与中文字符(最经典的用法)

场景:一个双语网站,同时有英文和中文内容。一个完整的中文字体(如思源黑体)通常也包含了完整的拉丁字符集,但文件体积巨大 (10-20MB)。如果用户只访问英文页面,加载整个中文字体是极大的浪费。

解决方案

  1. 准备两个字体子集

    • myfont-latin.woff2:只包含基础拉丁字符(字母、数字、标点)的子集,体积可能只有 15-20KB。
    • myfont-cjk.woff2:只包含常用中日韩字符的子集,体积可能为 1-2MB。
  2. 编写 CSS
/* 规则 1: 定义拉丁字符集字体 */
@font-face {
  font-family: "MyAwesomeFont"; /* 注意:font-family 名称必须相同 */
  src: url("fonts/myfont-latin.woff2") format("woff2");
  /* 告诉浏览器,这个文件只用于渲染基础拉丁字符和数字、标点 */
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
    U+FEFF, U+FFFD;
}

/* 规则 2: 定义中文字符集字体 */
@font-face {
  font-family: "MyAwesomeFont"; /* 再次强调:font-family 名称必须相同 */
  src: url("fonts/myfont-cjk.woff2") format("woff2");
  /* 告诉浏览器,这个文件只用于渲染中文字符 */
  unicode-range: U+4E00-9FFF, U+3000-303F; /* 覆盖了主要的中日韩统一表意文字和标点 */
}

/* 在页面中正常使用 */
body {
  font-family: "MyAwesomeFont", sans-serif;
}

效果

  • 当用户访问一个纯英文页面(如 <h1>Hello World</h1>),浏览器只会检查字符码点,发现它们都在第一个规则的 unicode-range 内,于是 只下载 myfont-latin.woff2 (20KB)
  • 当用户访问一个包含中文的页面(如 <h1>你好世界</h1>),浏览器发现 "你" (U+4F60) 等字符命中了第二个规则,于是会 同时下载 myfont-latin.woff2myfont-cjk.woff2(如果页面中同时有拉丁字符和中文字符的话)。

这样就实现了真正的按需加载,极大地提升了纯英文页面的加载性能。

案例 2:为特殊符号或图标创建单独的字体文件

场景:你的网站使用了一种特殊的字体来显示货币符号(如 , ¥, £)或自定义的图标字体。

解决方案

  1. 准备一个极小的字体子集

    • currency-symbols.woff2:只包含 , ¥, £, $ 四个字符。
  2. 编写 CSS
/* 系统默认字体 */
body {
  font-family: Arial, sans-serif;
}

/* 为特殊货币符号加载自定义字体 */
@font-face {
  font-family: "CurrencyFont";
  src: url("fonts/currency-symbols.woff2") format("woff2");
  /* 精确指定只包含这几个符号的 Unicode 码点 */
  unicode-range: U+20AC, U+00A5, U+00A3, U+0024; /* €, ¥, £, $ */
}

/* 在需要的地方混合使用 */
.price {
  /*
   * 浏览器渲染 .price 元素时:
   * 1. 遇到数字 "199",使用系统字体 Arial。
   * 2. 遇到货币符号 "€",发现其码点 U+20AC 命中了 'CurrencyFont' 的 unicode-range,
   *    于是下载 currency-symbols.woff2 并用它来渲染 "€"。
   */
  font-family: "CurrencyFont", Arial, sans-serif;
}

HTML: <span class="price">€199</span>

效果:只有在页面上实际出现了这几个货币符号时,那个几 KB 大小的 currency-symbols.woff2 文件才会被下载,实现了对核心字体的“打补丁”式加载。

案例 3:Google Fonts 动态子集化的背后功臣

这是 unicode-range 最高级的应用,我们在之前的讨论中已经见过。

场景:当你请求 https://fonts.googleapis.com/css2?family=Noto+Sans+SC&text=你好世界

Google 返回的 CSS

/* ... 其他拉丁字符等的 @font-face 规则 ... */

/* 针对 "你好世界" 的专属规则 */
@font-face {
  font-family: "Noto Sans SC";
  font-style: normal;
  font-weight: 400;
  src: url(https://fonts.gstatic.com/...) format("woff2"); /* 一个只包含这4个字的woff2文件 */

  /* 看这里!unicode-range 被精确地设置为这四个字的码点 */
  unicode-range: U+4f60, U+597d, U+4e16, U+754c;
}

效果

浏览器拿到了这个极其精确的指令。它知道,这个 src 对应的字体文件是“专供”这四个汉字的。这使得浏览器可以做出最智能的加载决策,将字体加载的粒度从“整个字符集”缩小到了“单个字符”。

利用本地字体回退

通过 local() 优先使用用户已安装的字体。

local() 是 CSS @font-face 规则中 src 属性的一个函数。它的作用是 指示浏览器在尝试从网络下载字体文件之前,先检查用户本地计算机上是否已经安装了同名的字体。

local() 必须写在 @font-facesrc 属性中,并且 强烈建议将其放在列表的第一个位置,这样浏览器才会优先检查它。

@font-face {
  font-family: "MyCustomFont";
  src: local("Font Name"),
    /* 优先检查本地 */ url("myfont.woff2") format("woff2"), /* 本地没有,再从网络下载 */
      url("myfont.woff") format("woff");
}

注意

  • local() 的参数 ('Font Name') 里的这个名称,并不是你随便起的 font-family 名称,而是字体在用户操作系统中 真正的名称。这通常是最容易出错的地方。
  • 为了提高命中率,你可以提供多个 local() 条目,覆盖不同的命名习惯和字体重量。
@font-face {
  font-family: "MyPingFang"; /* 这是我们自己起的名字 */
  font-style: normal;
  font-weight: 400; /* Regular 字重 */
  src: local("PingFang SC Regular"), /* 尝试全名 */ local("PingFang-SC-Regular"),
    /* 尝试 PostScript 名称变体 */ local("PingFang SC"),
    /* 尝试家族名称 */
      /* 如果本地都没有,再从我们自己的服务器下载一个思源黑体作为备用 */
      url("/fonts/SourceHanSansCN-Regular.woff2") format("woff2");
}

body {
  /* 这样,在苹果设备上会优先使用系统自带的、渲染效果最好的苹方字体,
    在其他设备上则会下载我们提供的思源黑体,实现了体验和性能的统一。*/
  font-family: "MyPingFang", sans-serif;
}
  • 使用 Google Fonts 时也加上 local()
    很多开发者直接从 Google Fonts 复制 CSS,但其实可以自己优化一下。

Google 提供的 CSS:

@font-face {
  font-family: "Roboto";
  src: url(https://fonts.gstatic.com/...) format("woff2");
  /* ... */
}

优化后的 CSS:

@font-face {
  font-family: "Roboto";
  src: local("Roboto"),
    /* 很多 Android 设备和装了 ChromeOS 的设备自带 Roboto */ local("Roboto-Regular"),
    url(https://fonts.gstatic.com/...) format("woff2");
  /* ... */
}

这样,如果用户系统里已经有 Roboto 字体,就可以避免一次对 fonts.gstatic.com 的网络请求。

压缩与缓存优化

  1. 服务器压缩字体源文件:通过 Brotli/Gzip 压缩传输字体文件。
  2. 长期缓存:通过设置字体文件头信息Cache-Control来缓存字体文件。

这几点需要通过服务器运维配置完成。

异步加载非关键字体

为什么要使用异步加载,我们需要理解问题的根源。当你在 HTML 的 <head> 中使用 <link> 标签来引入外部字体文件时:

<head>
  <link
    href="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
    rel="stylesheet"
  />
</head>

浏览器会按以下步骤处理:

  1. 解析 HTML:浏览器读到这个 <link> 标签。
  2. 请求 CSS 文件:它会暂停对页面的进一步渲染(特别是需要使用该字体的文本部分),去下载 href 中的 CSS 文件。
  3. 请求字体文件:该 CSS 文件内部通常包含一个或多个 @font-face 规则,指向真正的字体文件(如 .woff2)。浏览器接着去下载这些字体文件。
  4. 渲染页面:在字体文件下载并应用之前,依赖该字体的文本渲染会被阻塞。这会导致用户在一段时间内看到空白(FOIT, Flash of Invisible Text),或者在设置了 font-display: swap 后看到后备字体然后突然变化(FOUT, Flash of Unstyled Text)。

核心问题:这个过程发生在浏览器渲染的关键路径(Critical Rendering Path)上,占用了网络资源,并推迟了首次内容绘制(FCP)和最大内容绘制(LCP),影响了核心 Web 指标(Core Web Vitals)和用户体验。

通过 JavaScript 异步加载字体,我们可以将字体下载过程从关键渲染路径中分离出去。

主要优势

  1. 非阻塞渲染:浏览器可以立即使用后备字体(如系统默认字体)来渲染页面,不会等待自定义字体下载完成。这极大地加快了页面的初始显示速度。
  2. 更好的用户体验:用户能更快地看到内容,即使字体样式还不是最终版本。
  3. 精细的加载控制:你可以完全控制字体的加载时机、加载成功或失败后的行为。

方法一:现代原生方案 —— CSS Font Loading API

这是目前最推荐的、基于浏览器原生 API 的现代方法。它不需要任何外部库。

工作原理

document.fonts 是一个 FontFaceSet 对象,它提供了一系列方法来操作和检查字体加载状态。

  • document.fonts.load('1em MyFont'):返回一个 Promise。当指定的字体加载完成后,该 Promise 会 resolve。
  • document.fonts.check('1em MyFont'):同步检查字体是否已加载,返回 truefalse

实现步骤

  1. 初始 CSS:在 CSS 中,先为你的元素设置一个通用的后备字体。

    body {
      /* 先使用系统默认的无衬线字体,保证内容快速可见 */
      font-family: sans-serif;
    }
    
    /* 当字体加载成功后,我们会给 <html> 标签添加一个 .fonts-loaded 类 */
    .fonts-loaded body {
      /* 应用我们自定义的字体 */
      font-family: "Roboto", sans-serif;
    }
  2. JavaScript 加载:编写一个脚本来加载字体,并在加载成功后给 <html> 标签添加一个类名。

    // 检查浏览器是否支持 Font Loading API
    if ("fonts" in document) {
      // 创建 FontFace 对象
      const robotoFont = new FontFace(
        "Roboto",
        'url(/fonts/roboto.woff2) format("woff2")'
      );
    
      // 将字体添加到 document.fonts
      document.fonts.add(robotoFont);
    
      // 加载字体
      robotoFont
        .load()
        .then(() => {
          // 字体加载成功后,给 <html> 标签添加一个类
          document.documentElement.classList.add("fonts-loaded");
          console.log("Roboto font loaded successfully!");
        })
        .catch((error) => {
          console.error("Font loading failed:", error);
        });
    } else {
      // 对于不支持的旧浏览器,可以提供一个降级方案
      // 比如直接通过 link 标签插入,或者干脆不加载自定义字体
      console.warn("CSS Font Loading API not supported.");
    }
    
    // 如果要加载多个字体,可以使用 Promise.all
    async function loadFonts() {
      const font1 = new FontFace("MyFont1", "url(...)");
      const font2 = new FontFace("MyFont2", "url(...)");
    
      document.fonts.add(font1);
      document.fonts.add(font2);
    
      try {
        await Promise.all([font1.load(), font2.load()]);
        document.documentElement.classList.add("fonts-loaded");
      } catch (error) {
        console.error("One or more fonts failed to load:", error);
      }
    }
    
    loadFonts();

优点

  • 原生,无需额外库,性能开销小。
  • 符合现代 Web 标准,是未来的方向。
  • 提供了非常灵活的控制。

缺点

  • 需要处理旧浏览器的兼容性(不过现在主流浏览器支持度已经非常好)。

方法二:经典库方案 —— Web Font Loader

Web Font Loader 是一个由 Google 和 Typekit(现在的 Adobe Fonts)合作开发的库。它提供了一个跨浏览器的一致性方法来处理字体加载,尤其是在原生 CSS Font Loading API 尚未普及时,它是事实上的标准。

虽然现在有了原生 API,但它在一些旧项目或需要兼容非常古老浏览器的场景中仍然有用。

工作原理
Web Font Loader 会在字体加载的不同阶段,向 <html> 标签添加特定的 CSS 类名(如 .wf-loading, .wf-active, .wf-inactive),你可以利用这些类名来控制页面样式。

实现步骤

  1. 引入 Web Font Loader 脚本:你可以通过 CDN 异步加载它。

    <script
      src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"
      async
    ></script>
  2. 配置并启动加载:在页面中添加配置脚本。

    // 确保 WebFont 对象存在后再执行
    window.WebFontConfig = {
      // 从 Google Fonts 加载
      google: {
        families: ["Roboto:400,700"],
      },
      // 或者加载自定义字体
      custom: {
        families: ["MyCustomFont"], // 字体族名
        urls: ["/path/to/my/fonts.css"], // 包含 @font-face 规则的 CSS 文件
      },
      // 字体加载成功时执行的回调
      active: function () {
        console.log("All fonts have been loaded!");
        // 你也可以在这里执行一些操作,但更推荐使用 CSS 类
      },
      // 字体加载失败时
      inactive: function () {
        console.log("Some or all fonts failed to load");
      },
    };
    
    (function (d) {
      var wf = d.createElement("script"),
        s = d.scripts[0];
      wf.src = "https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js";
      wf.async = true;
      s.parentNode.insertBefore(wf, s);
    })(document);

    注意:上面代码片段是一种自执行的、更健壮的脚本注入方式。

  3. 利用 CSS 类控制样式:这是 Web Font Loader 的精髓。

    /* 默认状态,使用后备字体 */
    .my-element {
      font-family: sans-serif;
    }
    
    /*
     * Web Font Loader 会在字体加载完成并可用时,
     * 给 <html> 标签添加 .wf-active 类。
     */
    .wf-active .my-element {
      font-family: "Roboto", sans-serif;
    }
    
    /*
     * 在字体加载过程中,<html> 标签会有 .wf-loading 类,
     * 你可以用它来做一些过渡效果,但不常用。
     */
    .wf-loading .my-element {
      /* 比如,在加载时暂时隐藏文本,避免 FOUT,但这又回到了 FOIT 的问题 */
      /* visibility: hidden; */
    }

优点

  • 非常好的浏览器兼容性。
  • API 简单易用,配置清晰。
  • 对 Google Fonts, Typekit, Fonts.com 等服务有很好的集成。

缺点

  • 需要引入一个额外的 JS 库(虽然很小)。
  • 相较于原生 API,算是一个“过时”的解决方案,但依然稳定可靠。

注意

无论你用哪种异步加载方法,都应该在你的 @font-face 规则中配合使用 font-display 属性。它能告诉你浏览器在字体下载期间应该如何处理文本。

@font-face {
  font-family: "Roboto";
  src: url("/fonts/roboto.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  /* 关键属性 */
  font-display: swap;
}
  • swap (推荐): 立即使用后备字体显示文本。当自定义字体下载完成后,再“交换”过来。这会产生 FOUT,但能确保内容始终可见。
  • block: 短时间内(约 3 秒)阻塞渲染,如果字体还没下载好,就使用后备字体。这是 FOIT 的主要来源。
  • fallback: 阻塞时间非常短(约 100 毫秒)。如果字体没加载好,就直接使用后备字体,并且之后不再更新。
  • optional: 和 fallback 类似,但浏览器可以根据网络状况决定是否下载字体。如果网络不好,可能干脆不加载。

对于追求极致性能和体验的网站,font-display: swap; 结合 JavaScript 异步加载是黄金组合。

使用系统字体作为回退

优先使用系统字体是我这些年用的最多的一种方式,但是它很难统一不同系统之间的字体差异,比如 mac 和 windows,他们的系统字体都不一样。

常见的解决思路是:创建一个 font-family 的字体列表,优先使用用户操作系统自带的、最优化的 UI 字体,如果找不到,则优雅地降级到下一个,直到最后的通用字体族。

我这里提供几份常用的示例:

1. 无衬线字体(Sans-Serif)- 最常用

这是适用于绝大多数网站正文、标题和 UI 元素的推荐代码。

body {
  /*
   * 这是目前最健壮的无衬线系统字体栈 (Sans-Serif System Font Stack)
   * 它覆盖了 Apple, Windows, Android, Linux 等主流操作系统。
   */
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
    "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
    "Helvetica Neue", sans-serif;
}

代码解析:

  1. -apple-system: 针对 Safari (macOS 和 iOS)。它会调用系统 UI 字体。在较新版本中是 "San Francisco",在旧版本中是 "Helvetica Neue" 或 "Lucida Grande"。
  2. BlinkMacSystemFont: 针对 Chrome (macOS)。这是 Chrome 团队为了和 -apple-system 达到同样效果而创建的别名。把这两个都写上可以确保在 Apple 平台上的所有现代浏览器都使用系统字体 "San Francisco"。
  3. 'Segoe UI': 针对 Windows 和 Windows Phone。这是 Windows Vista 及以后版本的主要 UI 字体,清晰易读。
  4. 'Roboto': 针对 Android 和较新的 Chrome OS。这是 Android 的标准字体。
  5. 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans': 这一组是针对各种主流 Linux 发行版的。

    • Oxygen for KDE
    • Ubuntu for Ubuntu
    • Cantarell for GNOME
    • Fira Sans for Firefox OS
    • Droid Sans 是旧版 Android 的字体,也是一个不错的后备选项。
  6. 'Helvetica Neue': 针对旧版的 macOS,也是一个在很多系统上都可能安装的经典高质量无衬线字体。
  7. sans-serif: 最后的防线。这是一个 CSS 通用字体族关键字。如果前面的所有字体在用户系统上都找不到,浏览器会使用其默认的无衬线字体(在 Windows 上通常是 Arial,在一些 Linux 上可能是 DejaVu Sans)。这确保了文本永远有可用的字体来渲染。

2. 等宽字体(Monospace)

当你需要为代码片段(<code>, <pre>), 键盘输入(<kbd>)等场景设置字体时,一个健壮的等宽字体栈同样重要。

pre,
code,
kbd,
samp {
  /* 健壮的等宽系统字体栈 (Monospace System Font Stack) */
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
    monospace;
}

代码解析:

  1. 'SFMono-Regular': Apple 的等宽字体 (San Francisco Mono)。
  2. Consolas: Microsoft 为编程设计的优秀等宽字体,随 Windows 和 Office 一起分发。
  3. 'Liberation Mono': 一个常见的 Linux 等宽字体,是 Courier New 的一个开源替代品。
  4. Menlo: 较旧版本 macOS 上的标准等宽字体。
  5. Courier: 一个非常古老的、几乎所有系统都有的等宽字体,作为可靠的后备。
  6. monospace: 最后的防线。如果以上皆无,浏览器将使用其默认的等宽字体。

3. 项目中的最佳实践

为了方便管理和复用,推荐使用 CSS 自定义属性(CSS Variables)来定义这些字体栈。

:root {
  --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
    "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
    "Helvetica Neue", sans-serif;
  --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
    monospace;
}

/* 全局应用无衬线字体 */
body {
  font-family: var(--font-sans);
}

/* 为特定元素应用等宽字体 */
pre,
code,
kbd,
samp {
  font-family: var(--font-mono);
}

这样,你就在整个项目中建立了一个统一、高效且体验良好的字体系统。这份代码经过了社区(如 WordPress, Bootstrap, GitHub 等大型项目)多年的检验,可以说是目前最可靠的系统字体解决方案。

可变字体(Variable Fonts)

可变字体是一种相对于传统字体而言的新型字体技术,他可以让同一个字体通过不同的变化轴实现不同的展示效果,比如:字重(Weight)、字宽(Width)、倾斜(Slant)等。通过控制不同轴的数值,可以创造出几乎无限的字体样式。

核心概念:变化轴(Axes of Variation)

变化轴是可变字体的核心。主要分为两种:

  1. 注册轴(Registered Axes):这是五个预定义的、标准化的轴,浏览器有很好的支持,并且通常有对应的 CSS 属性。

    • wght (Weight / 字重): 控制字体的粗细,对应 font-weight 属性。
    • wdth (Width / 字宽): 控制字符的宽度,对应 font-stretch 属性。
    • slnt (Slant / 倾斜): 控制字符的倾斜角度,对应 font-style: oblique <angle>
    • ital (Italic / 斜体): 控制是否切换到斜体字形(通常是 0 或 1),对应 font-style: italic
    • opsz (Optical Size / 视觉尺寸): 自动优化字体在不同大小下的显示效果。例如,在小字号下,笔画会更清晰、间距更大,以提高可读性。
  2. 自定义轴(Custom Axes):字体设计师可以创建任何他们想要的轴,通常用四个大写字母表示,例如 GRAD (Grade,在不改变字宽的情况下调整字重)、WONK (Wonky,古怪度) 等。这些轴提供了巨大的创意空间。

为什么要使用可变字体?

  1. 性能优化

    • 减少文件大小和 HTTP 请求:只需加载一个字体文件,而不是 5、6 个甚至更多。这大大减少了网络请求数量和总体下载量,显著提升网站加载速度。
  2. 设计灵活性与精确控制

    • 你不再局限于 font-weight: 400(常规)和 font-weight: 700(粗体)。你可以设置 font-weight: 451683,实现设计师想要的任何精确字重。
    • 可以微调字宽,让标题在特定容器内完美填充,而不会折行。
  3. 响应式排版

    • 字体可以根据视口大小动态调整。例如,在小屏幕上使用稍窄、稍细的字体以节省空间,在大屏幕上则使用更宽、更粗的字体以增强视觉冲击力。
  4. 丰富的动画与交互效果

    • 由于字体样式是连续变化的,你可以使用 CSS transition 平滑地为字体属性添加动画效果,例如鼠标悬停时文字平滑地变粗、变宽,创造出非常优雅的交互体验。

如何在 CSS 中使用可变字体?

使用可变字体分为两步:加载字体和应用样式。

第 1 步:加载可变字体 (@font-face)

这和加载常规字体非常相似。关键在于,你只需要一个 src 指向那个单一的可变字体文件。现代浏览器会自动识别它为可变字体。

@font-face {
  font-family: "Inter Variable";
  font-style: normal; /* 可以定义一个范围,但通常 normal 即可 */
  font-weight: 100 900; /* 声明支持的字重范围 */
  font-display: swap; /* 推荐,提升用户体验 */
  src: url("Inter-Variable.woff2") format("woff2-variations"); /* 优先使用 */
}
  • font-weight: 100 900;:在这里声明字体支持的字重范围,是一个好习惯。
  • format('woff2-variations'):明确告诉浏览器这是一个可变字体。不过,现代浏览器即使你只写 format('woff2') 也能正确识别。

第 2 步:应用可变样式

有两种方式来控制可变字体的轴:

方法一 使用高层级 CSS 属性(推荐用于注册轴)

对于标准化的注册轴,优先使用我们已经熟悉的 CSS 属性。这种方式语义化更好,且对不支持可变字体的浏览器有更好的降级方案。

h1 {
  font-family: "Inter Variable", sans-serif;

  /* 控制字重 (wght) */
  font-weight: 750; /* 不再是 100 的倍数,可以是任意值 */

  /* 控制字宽 (wdth) */
  font-stretch: 125%; /* 变宽 */

  /* 控制倾斜 (slnt) */
  font-style: oblique 10deg;
}

方法二 使用低层级 font-variation-settings 属性

这个属性是控制可变字体的“万能钥匙”。它主要用于:

  • 控制自定义轴
  • 当需要比高层级属性更精细的控制时。
  • 某些旧版浏览器可能只支持这个属性。

语法是 font-variation-settings: 'AXIS' value, 'AXIS' value, ...;,轴名称必须是 4 个字符的字符串。

.custom-style {
  font-family: "MyCustomVariableFont", sans-serif;

  /* 使用 font-variation-settings 控制注册轴和自定义轴 */
  font-variation-settings: "wght" 450, /* 字重 */ "wdth" 85,
    /* 字宽(85%) */ "GRAD" 150; /* 一个名为 'GRAD' 的自定义轴 */
}

最佳实践

优先使用 font-weight, font-stretch 等。仅在需要控制自定义轴,或者目标浏览器不支持高层级属性时,才使用 font-variation-settings

常见的案例

案例 1 响应式标题

场景:在手机上,标题需要稍微收窄以避免尴尬的换行;在桌面上,则可以更舒展。

h1 {
  font-family: "Amstelvar", serif;
  font-variation-settings: "wdth" 100; /* 默认宽度 */
  transition: font-variation-settings 0.4s ease;
}

/* 在小屏幕设备上 */
@media (max-width: 600px) {
  h1 {
    /* 将字宽收缩到 80% */
    font-variation-settings: "wdth" 80;
    font-weight: 600; /* 也可以同时调整字重 */
  }
}

/* 在大屏幕设备上 */
@media (min-width: 1200px) {
  h1 {
    /* 将字宽拉伸到 110% */
    font-variation-settings: "wdth" 110;
    font-weight: 750;
  }
}

案例 2 平滑的鼠标悬停交互

场景:当鼠标悬停在一个链接或按钮上时,文字平滑地变粗,提供优雅的视觉反馈。

<a href="#" class="interactive-link">Hover over me</a>
.interactive-link {
  font-family: "Inter Variable", sans-serif;
  font-weight: 400;
  text-decoration: none;
  color: #333;
  font-size: 2rem;

  /* 关键:为 font-weight 添加过渡效果 */
  transition: font-weight 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}

.interactive-link:hover {
  /* 鼠标悬停时,平滑地增加字重 */
  font-weight: 800;
}

对比:如果用传统字体,font-weight 从 400 到 800 的变化是瞬间的、生硬的。而使用可变字体,这个过程是丝滑的动画。

案例 3 暗黑模式下的可读性优化

场景:在深色背景下,白色或浅色文字因为光晕效应(halation)会显得比在浅色背景下更细。我们可以用 GRADopsz 轴来微调,增加笔画的“墨水感”,同时不改变字符的宽度。

body {
  font-family: "Roboto Flex", sans-serif;
  font-variation-settings: "GRAD" 0; /* 默认 Grade */
  transition: font-variation-settings 0.3s;
}

body.dark-mode {
  background-color: #121212;
  color: #eee;
  /* 在暗黑模式下,稍微增加 Grade 值,让文字看起来更“实” */
  font-variation-settings: "GRAD" 50;
}

哪里去找可变字体?

  • Google Fonts: 最好的入门资源。在网站上筛选时,勾选 "Show only variable fonts" 即可。例如 Inter, Roboto Flex, Work Sans 等都提供可变版本。
  • v-fonts.com: 一个专门展示和测试可变字体的网站。
  • 独立字体铸造厂:如 DJR, Klim Type Foundry 等也提供了优秀的可变字体。
  • 国内开源的:鸿蒙字体 HarmonyOS Sans

浏览器支持情况

所有现代浏览器(Chrome, Firefox, Safari, Edge)都已全面支持可变字体。兼容性非常好。对于需要支持 IE11 的古老项目,则需要提供静态字体作为降级方案。

按条件加载字体

在实际开发中,我们常常需要根据不同场景(如屏幕尺寸、配色方案、打印模式等)加载不同的字体文件,以提升页面性能与视觉表现。例如:

  • 在移动设备上避免加载大而复杂的品牌字体,提升加载速度;
  • 在深色模式与浅色模式下使用不同字重或风格的字体;
  • 打印页面时切换为更适合纸质阅读的字体。

但是要注意:

CSS 官方规范不支持在 @font-face 内使用 media 描述符
现有标准推荐的做法是将 @font-face 嵌套在 @media 查询中,控制字体的生效条件。

基本用法:配合 @media 实现条件字体加载

/* 通用字体定义 */
@font-face {
  font-family: "MyFont";
  src: url("./fonts/MyFont-Regular.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}

/* 针对深色模式 */
@media (prefers-color-scheme: dark) {
  @font-face {
    font-family: "MyFont";
    src: url("./fonts/MyFont-Dark.woff2") format("woff2");
    font-weight: 400;
    font-display: swap;
  }
}

浏览器根据媒体查询条件,只加载当前环境所需的字体文件,避免浪费带宽。

场景一:根据配色模式(亮/暗模式)加载字体

需求

  • 浅色模式:加载 MyFont-Regular.woff2
  • 深色模式:加载 MyFont-Bold.woff2

实现

/* 亮色模式字体(默认) */
@font-face {
  font-family: "ThemeFont";
  src: url("./fonts/MyFont-Regular.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}

/* 深色模式字体 */
@media (prefers-color-scheme: dark) {
  @font-face {
    font-family: "ThemeFont";
    src: url("./fonts/MyFont-Bold.woff2") format("woff2");
    font-weight: 400;
    font-display: swap;
  }
}

body {
  font-family: "ThemeFont", sans-serif;
}

场景二:根据屏幕尺寸加载不同字体

需求

  • 移动端:使用系统字体(不加载自定义字体)。
  • 桌面端:加载品牌展示字体 BrandFont.woff2

实现

/* 桌面端专用字体 */
@media (min-width: 1024px) {
  @font-face {
    font-family: "BrandFont";
    src: url("./fonts/BrandFont.woff2") format("woff2");
    font-display: swap;
  }
}

h1 {
  font-family: "BrandFont", system-ui, sans-serif;
}

移动设备不会加载 BrandFont.woff2,提高移动端首屏速度。

场景三:优化打印样式字体

需求

  • 屏幕浏览:无衬线字体 Open Sans
  • 打印时:衬线字体 Merriweather,适合纸质阅读。

实现

/* 屏幕显示字体 */
@media screen {
  @font-face {
    font-family: "ContentFont";
    src: url("./fonts/OpenSans-Regular.woff2") format("woff2");
    font-display: swap;
  }
}

/* 打印专用字体 */
@media print {
  @font-face {
    font-family: "ContentFont";
    src: url("./fonts/Merriweather-Regular.woff2") format("woff2");
    font-display: swap;
  }
}

body {
  font-family: "ContentFont", sans-serif;
}

用户点击“打印”时才加载打印字体,避免平时页面加载浪费流量。

性能监控与测试

使用 Lighthouse 或 WebPageTest 检测字体加载对页面性能的影响,优化 FCP(First Contentful Paint)和 LCP(Largest Contentful Paint)。

这个目前也只是了解一下,谷歌浏览器的开发者工具就够我们日常查看了。

分类: CSS 标签: 压缩字体font-facefont-family缓存字体子集化unicode-range异步加载可变字体Variable Fontsmedia

评论

全部评论 1

  1. wu先生
    wu先生
    Google Chrome Windows 10
    技术文。会折腾啊。

目录