前端字体优化
前言
想写这篇文章已经很久很久了,最近狠下心来整理这些资料,希望能帮助到大家。
合适的字体格式
字体文件有很多种:ttf、otf、eot、svg、woff、woff2;不同的格式用于不同的使用场景,而且大部分格式其实都不推荐使用,最终最值得使用的就是 woff2 格式。
格式推荐优先级
- WOFF2:面向所有现代浏览器,性能最优。
- WOFF:面向稍旧一些的浏览器,作为 WOFF2 的主要备选。
- TTF/OTF:面向更老旧的移动浏览器。
- EOT (可选):仅在需要兼容 IE8 及以下浏览器时才添加。
- 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;
}
表格汇总
格式 | 压缩算法 | 推荐度 | 主要适用场景 |
---|---|---|---|
WOFF2 | Brotli | ★★★★★ (最高) | 所有现代浏览器,性能首选 |
WOFF | zlib/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) 问题。
在这之前我们需要了解下浏览器加载网页字体的行为:
- 浏览器开始渲染页面,发现需要一个自定义字体(通过
@font-face
指定)。- 浏览器开始下载这个字体文件。
- 在字体文件完全下载并准备好之前,使用了该字体的文本将是不可见的。
- 字体下载完成后,文本突然出现。
这个过程导致了一个糟糕的用户体验,被称为 “不可见文本闪烁” (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
的五个值及其行为:
为了更好地理解这五个值,我们可以想象一个 “字体显示时间线”,它分为三个阶段:
- 字体阻塞期 (Block Period):如果字体没加载出来,浏览器会渲染不可见的占位文本。在此期间,浏览器等待字体。
- 字体交换期 (Swap Period):如果在阻塞期后字体仍未加载出来,浏览器会显示后备字体 (Fallback Font)。在此期间,浏览器仍然在等待字体,一旦加载成功,就会交换成自定义字体。
- 字体失败期 (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 甚至更小。
其主要使用场景如下:
- 固定文本内容(最常见、效果最好),比如文章标题、活动标题、按钮文字等。
- 整篇文章或页面的静态内容。
- 动态内容(技术要求高),比如用户评论、新闻 feed 流等,内容是动态生成的,你无法预知会用到哪些汉字。
比较好理解的就是 1、2 两点,因为这些内容都是固定不变的,你只需要把字体文件精简到只包含你需要的字符即可。
动态子集化
动态子集化是指 在服务器端根据每次请求的具体文本内容,实时生成一个只包含这些文本所需字符的字体子集,然后将其返回给客户端。
核心流程:
- 客户端(浏览器):请求一个页面或数据,比如一篇新闻文章。
- 服务器端:
a. 接收到请求,获取到将要显示的文本内容(例如,从数据库中查询出的文章正文)。
b. 分析文本:提取出文本中所有不重复的字符。
c. 实时生成字体:调用字体处理库(如 HarfBuzz、fonttools),以一个完整的字体文件为模板,根据提取出的字符列表,实时生成一个极小的 WOFF2 或 WOFF 字体子集。
d. 返回响应:将页面 HTML 和一个指向这个动态生成字体的 CSS(或直接内联 CSS)一起返回给浏览器。 - 客户端(浏览器):收到 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),但远不如动态生成的子集那么极致。
自己手动实现动态子集化
如果你想自己搭建一套这样的服务,通常需要以下几个核心部分:
API 网关 (API Gateway)
- 职责:接收前端请求,处理认证、限流等。
- 技术选型:Nginx, Express.js, Spring Boot 等。
缓存层 (Caching Layer)
- 职责:存储已生成的字体子集映射关系,是整个系统性能的保障。
- 技术选型:Redis, Memcached。
字体子集化 Worker (Font Subsetting Worker)
- 职责:执行真正的字体裁剪工作,这是计算密集型任务。
- 技术选型:强烈推荐 Python +
fonttools
库。fonttools
是目前最强大、最专业的开源字体处理工具。可以通过消息队列(如 RabbitMQ, Kafka)与主应用解耦。
对象存储 (Object Storage)
- 职责:永久存放生成的字体文件。
- 技术选型:AWS S3, Google Cloud Storage, 阿里云 OSS, 或自建 MinIO。
内容分发网络 (CDN)
- 职责:在全球范围内加速字体文件的分发,降低延迟。
- 技术选型:Cloudflare, Akamai, 或云服务商自带的 CDN。
很可惜的是我目前没找到大厂的一些资料和文献,一些相关的内容,比如:《GMTC Bilibili 字体优化》、《Bilibili 主站动态字体与 fallback 实践》、《Iconfont—设计师和工程师的好朋友》这些文章都英文时间的原因,链接都已经失效了,所以大家有兴趣只能自行搜索相关资料。
知识补充 - unicode-range
unicode-range
经常出现在字体优化的方案中,上面的子集化也用到了这个属性。简单来说,unicode-range
的作用就像一个 “字体加载的条件触发器”。
它在 @font-face
规则中,告诉浏览器:“我定义的这个字体文件(src
指向的文件),只包含特定 Unicode 编码范围内的字符。只有当你需要渲染这个范围内的字符时,才去下载这个字体文件。”
核心解决的问题:
避免加载一个包含数万字符的完整字体库,而当前页面可能只需要其中的几十个字符。它允许你将一个大字体分割成多个小块,然后按需加载。
工作原理:
- 浏览器解析 CSS,看到多个使用了 相同
font-family
名称 但 不同unicode-range
的@font-face
规则。 - 浏览器并不立即下载所有这些字体文件。
- 当浏览器开始渲染页面内容时,它会检查每个字符的 Unicode 码点。
- 如果一个字符的码点命中了某个
@font-face
规则中定义的unicode-range
,浏览器才会去下载该规则src
指向的那个字体文件。 - 一旦下载完成,该字体文件就会被用来渲染所有匹配其
unicode-range
的字符。
unicode-range
的语法:
unicode-range
的值可以是一个或多个用逗号分隔的 Unicode 范围。
- 单个码点 (Single Codepoint):只匹配一个字符。
unicode-range: U+26;
只对 Unicode 字符 & (与号) 生效。 - 码点区间 (Range):匹配一个连续的字符范围。
unicode-range: U+0000-00FF;
匹配所有基础拉丁字符。 - 通配符范围 (Wildcard Range):使用
?
作为通配符,代表任意十六进制数字。unicode-range: U+4??;
匹配从 U+400 到 U+4FF 的所有字符。 - 组合使用:将多个范围用逗号隔开。
unicode-range: U+0025-00FF, U+4??, U+2190-2199;
实践案例:
unicode-range
的威力只有在与 字体子集化 结合时才能完全体现。下面是几个从简单到高级的实践案例。
案例 1:分离基础西文与中文字符(最经典的用法)
场景:一个双语网站,同时有英文和中文内容。一个完整的中文字体(如思源黑体)通常也包含了完整的拉丁字符集,但文件体积巨大 (10-20MB)。如果用户只访问英文页面,加载整个中文字体是极大的浪费。
解决方案:
准备两个字体子集:
myfont-latin.woff2
:只包含基础拉丁字符(字母、数字、标点)的子集,体积可能只有 15-20KB。myfont-cjk.woff2
:只包含常用中日韩字符的子集,体积可能为 1-2MB。
- 编写 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.woff2
和myfont-cjk.woff2
(如果页面中同时有拉丁字符和中文字符的话)。
这样就实现了真正的按需加载,极大地提升了纯英文页面的加载性能。
案例 2:为特殊符号或图标创建单独的字体文件
场景:你的网站使用了一种特殊的字体来显示货币符号(如 €
, ¥
, £
)或自定义的图标字体。
解决方案:
准备一个极小的字体子集:
currency-symbols.woff2
:只包含€
,¥
,£
,$
四个字符。
- 编写 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-face
的 src
属性中,并且 强烈建议将其放在列表的第一个位置,这样浏览器才会优先检查它。
@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
的网络请求。
压缩与缓存优化
- 服务器压缩字体源文件:通过 Brotli/Gzip 压缩传输字体文件。
- 长期缓存:通过设置字体文件头信息
Cache-Control
来缓存字体文件。
这几点需要通过服务器运维配置完成。
异步加载非关键字体
为什么要使用异步加载,我们需要理解问题的根源。当你在 HTML 的 <head>
中使用 <link>
标签来引入外部字体文件时:
<head>
<link
href="https://fonts.googleapis.com/css2?family=Roboto&display=swap"
rel="stylesheet"
/>
</head>
浏览器会按以下步骤处理:
- 解析 HTML:浏览器读到这个
<link>
标签。 - 请求 CSS 文件:它会暂停对页面的进一步渲染(特别是需要使用该字体的文本部分),去下载
href
中的 CSS 文件。 - 请求字体文件:该 CSS 文件内部通常包含一个或多个
@font-face
规则,指向真正的字体文件(如.woff2
)。浏览器接着去下载这些字体文件。 - 渲染页面:在字体文件下载并应用之前,依赖该字体的文本渲染会被阻塞。这会导致用户在一段时间内看到空白(FOIT, Flash of Invisible Text),或者在设置了
font-display: swap
后看到后备字体然后突然变化(FOUT, Flash of Unstyled Text)。
核心问题:这个过程发生在浏览器渲染的关键路径(Critical Rendering Path)上,占用了网络资源,并推迟了首次内容绘制(FCP)和最大内容绘制(LCP),影响了核心 Web 指标(Core Web Vitals)和用户体验。
通过 JavaScript 异步加载字体,我们可以将字体下载过程从关键渲染路径中分离出去。
主要优势:
- 非阻塞渲染:浏览器可以立即使用后备字体(如系统默认字体)来渲染页面,不会等待自定义字体下载完成。这极大地加快了页面的初始显示速度。
- 更好的用户体验:用户能更快地看到内容,即使字体样式还不是最终版本。
- 精细的加载控制:你可以完全控制字体的加载时机、加载成功或失败后的行为。
方法一:现代原生方案 —— CSS Font Loading API
这是目前最推荐的、基于浏览器原生 API 的现代方法。它不需要任何外部库。
工作原理:
document.fonts
是一个 FontFaceSet
对象,它提供了一系列方法来操作和检查字体加载状态。
document.fonts.load('1em MyFont')
:返回一个 Promise。当指定的字体加载完成后,该 Promise 会 resolve。document.fonts.check('1em MyFont')
:同步检查字体是否已加载,返回true
或false
。
实现步骤:
初始 CSS:在 CSS 中,先为你的元素设置一个通用的后备字体。
body { /* 先使用系统默认的无衬线字体,保证内容快速可见 */ font-family: sans-serif; } /* 当字体加载成功后,我们会给 <html> 标签添加一个 .fonts-loaded 类 */ .fonts-loaded body { /* 应用我们自定义的字体 */ font-family: "Roboto", sans-serif; }
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
),你可以利用这些类名来控制页面样式。
实现步骤:
引入 Web Font Loader 脚本:你可以通过 CDN 异步加载它。
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js" async ></script>
配置并启动加载:在页面中添加配置脚本。
// 确保 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);
注意:上面代码片段是一种自执行的、更健壮的脚本注入方式。
利用 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;
}
代码解析:
-apple-system
: 针对 Safari (macOS 和 iOS)。它会调用系统 UI 字体。在较新版本中是 "San Francisco",在旧版本中是 "Helvetica Neue" 或 "Lucida Grande"。BlinkMacSystemFont
: 针对 Chrome (macOS)。这是 Chrome 团队为了和-apple-system
达到同样效果而创建的别名。把这两个都写上可以确保在 Apple 平台上的所有现代浏览器都使用系统字体 "San Francisco"。'Segoe UI'
: 针对 Windows 和 Windows Phone。这是 Windows Vista 及以后版本的主要 UI 字体,清晰易读。'Roboto'
: 针对 Android 和较新的 Chrome OS。这是 Android 的标准字体。'Oxygen'
,'Ubuntu'
,'Cantarell'
,'Fira Sans'
,'Droid Sans'
: 这一组是针对各种主流 Linux 发行版的。Oxygen
for KDEUbuntu
for UbuntuCantarell
for GNOMEFira Sans
for Firefox OSDroid Sans
是旧版 Android 的字体,也是一个不错的后备选项。
'Helvetica Neue'
: 针对旧版的 macOS,也是一个在很多系统上都可能安装的经典高质量无衬线字体。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;
}
代码解析:
'SFMono-Regular'
: Apple 的等宽字体 (San Francisco Mono)。Consolas
: Microsoft 为编程设计的优秀等宽字体,随 Windows 和 Office 一起分发。'Liberation Mono'
: 一个常见的 Linux 等宽字体,是Courier New
的一个开源替代品。Menlo
: 较旧版本 macOS 上的标准等宽字体。Courier
: 一个非常古老的、几乎所有系统都有的等宽字体,作为可靠的后备。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)
变化轴是可变字体的核心。主要分为两种:
注册轴(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 / 视觉尺寸): 自动优化字体在不同大小下的显示效果。例如,在小字号下,笔画会更清晰、间距更大,以提高可读性。
- 自定义轴(Custom Axes):字体设计师可以创建任何他们想要的轴,通常用四个大写字母表示,例如
GRAD
(Grade,在不改变字宽的情况下调整字重)、WONK
(Wonky,古怪度) 等。这些轴提供了巨大的创意空间。
为什么要使用可变字体?
性能优化:
- 减少文件大小和 HTTP 请求:只需加载一个字体文件,而不是 5、6 个甚至更多。这大大减少了网络请求数量和总体下载量,显著提升网站加载速度。
设计灵活性与精确控制:
- 你不再局限于
font-weight: 400
(常规)和font-weight: 700
(粗体)。你可以设置font-weight: 451
或683
,实现设计师想要的任何精确字重。 - 可以微调字宽,让标题在特定容器内完美填充,而不会折行。
- 你不再局限于
响应式排版:
- 字体可以根据视口大小动态调整。例如,在小屏幕上使用稍窄、稍细的字体以节省空间,在大屏幕上则使用更宽、更粗的字体以增强视觉冲击力。
丰富的动画与交互效果:
- 由于字体样式是连续变化的,你可以使用 CSS
transition
平滑地为字体属性添加动画效果,例如鼠标悬停时文字平滑地变粗、变宽,创造出非常优雅的交互体验。
- 由于字体样式是连续变化的,你可以使用 CSS
如何在 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)会显得比在浅色背景下更细。我们可以用 GRAD
或 opsz
轴来微调,增加笔画的“墨水感”,同时不改变字符的宽度。
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)。
这个目前也只是了解一下,谷歌浏览器的开发者工具就够我们日常查看了。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
全部评论 1
wu先生
Google Chrome Windows 10