出处:掘金

原作者:fengma1992


前言

本文将从字体加载、字体传输、字体渲染三个部分,介绍字体如何在对应生命周期内工作并给出优化方法

字体优化其实就两种方案:

  1. 提高 Web 字体加载 / 传输速度,在用户感知前加载完成
  2. 调整 Web 字体或后备字体的渲染参数(Ascent / Descent /Line Gap),避免字体切换时出现布局偏移

Web 字体影响性能主要体现在两方面:

  1. 延迟文本渲染:在 Web 字体完成加载前,浏览器会延迟文本渲染。这将影响 First Contentful Paint 首次内容绘制 (FCP),有时也会影响 Largest Contentful Paint 最大内容绘制 (LCP)
  2. 布局偏移:浏览器切换字体时有可能造成布局偏移,进而影响 Cumulative Layout Shift 累积布局偏移 (CLS)

前端性能指标详见:前端性能指标

为什么字体会导致布局变化?

下载 Web 字体时,当字体从后备字体切换为 Web 字体时,会导致包含元素(例如 <div>)的大小发生变化,从而导致布局发生变化。当 Web 字体的字体度量(Font Metrics)与后备字体相比不同时,就会出现这种情况。同时,布局页面时,浏览器将使用字体的尺寸和属性来确定包含元素的大小,即使你已声明 font-display: block

注意:两种不同的字体是可能会导致布局发生变化的,但不是一定变化,这主要取决于字体的字体高度

字体加载

字体是网页典型的重要资源,没有字体可能导致页面白屏。因此,我们需要尽可能早的加载字体

前端对字体文件的引用方式

首先来简单回顾下自定义的字体是如何在浏览器中完成渲染的,一般情况下对字体文件的引用方式有三种:

  1. 通过绝对路径来引用,这种就是将字体文件打包在工程内,所以带来的结果就是工程打包文件体积太大
@font-face {
  font-family: 'xxx';
  src: url('../../assets/fonts.woff2')
}
  1. CDN 中存放的字体文件,一般是通过这种方式来减少工程的编译后体积
@font-face {
  font-family: 'xxx';
  src: url('https://xxx.woff2')
}
  1. 通过 FontFace 构造一个字体对象

前两种一般是在浏览器构建 CSSOM 时,当遇到字体引用时会发起资源请求。第三种则是通过 JS 来控制字体的加载流程

浏览器默认行为

针对 CSS 内单独声明的 fomt-family,浏览器的字体加载可能有延迟。例如:

p {
  font-family: 'MyFont', -apple-system;
}

字体的延迟加载可能会延迟文本渲染。浏览器必须先构建依赖于 DOM 和 CSSOM 的 Render 树,然后才能知道它需要哪些字体资源来渲染文本。因此,字体会在其他关键资源请求之后延迟很长时间才开始请求,并且在获取字体资源之前浏览器可能阻塞文本渲染

  1. 浏览器请求 HTML 文档
  2. 浏览器开始解析 HTML 响应并构建 DOM
  3. 浏览器发现 CSS、JS 和其他资源并调度请求
  4. 浏览器在接收到所有 CSS 内容后构建 CSSOM,并将其与 DOM 树组合以构建 Render 树
    • 字体请求在 Render 树确定需要哪些字体来渲染页面上的指定文本后触发
  5. 浏览器执行布局并将内容绘制到屏幕上
    • 如果字体尚不可用,浏览器可能不会渲染任何文本
    • 字体可用后,浏览器会渲染文本

浏览器渲染流程详见:浏览器渲染流程

页面内容的第一次绘制与对字体资源的请求之间的“竞争”是造成“空白文本问题”的原因,浏览器可能会渲染页面布局但忽略任何文本

注意,有一个误解就是只要在 @font-face 内声明的 Web 字体就一定会被浏览器下载。实际上,Render 树中只有真正用到的 Web 字体才会下载,未用到的不会下载。下面代码示例中,MI-Lan-Pro 字体在 CSS 内的 @font-faceh2 中有声明,但 DOM 中并未使用到 h2 标签,因此字体不会被下载:

<!DOCTYPE html>
<html lang="en">
 
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    @font-face {
      font-family: 'MILanPro';
      src: url("https://cdn.cnbj1.fds.api.mi-img.com/mi-font-service/mi_lan_pro/400/lt.2c91ce20135e2e34.0.woff2") format("woff2");
    }
 
    h2 {
      font-family: MILanPro;
    }
  </style>
  <title>浏览器字体加载</title>
</head>
 
<body>
  <div>MILanPro 字体在 CSS 内的 @font-face 和 h2 中有声明,但 DOM 中并未使用到 h2 标签,因此字体不会被下载</div>
  <h1>Xiaomi</h1>
</body>
 
</html>

字体加载优化方案

内联字体声明

内联关键字体声明到 HTML head 标签中,而不是在单独的 CSS 文件中声明。这样可以让浏览器尽早发现字体声明而不是等到单独的 CSS 文件加载完成后才发现

<head>
    <style>
      @font-face {
        font-family: 'MyFont';
        src: url(https://font.com/myFont.woff2) format('woff2');
      }
      body {
        font-family: MyFont;
      }
    </style>
</head>

通过预加载字体提高加载速度

如果知道页面肯定会用到的字体,那么可以利用资源优先级,使用 <link rel="preload"> 提前触发对字体的请求,而无需等待创建 Render 树。通过预加载来防止布局偏移和不可见文本闪烁 (Flash of Invisible Text, FOIT)

从 Chrome 83 开始,可以将 <link rel="preload">font-display: optional 组合来完全消除布局卡顿

<link rel="preload" href="https://font.com/myFont.woff2" as="font" type="font/woff2" crossorigin>
  • as="font" type="font/woff2" 属性会告诉浏览器将此资源作为字体下载,并帮助确定资源队列的优先级
  • crossorigin 属性说明是否应使用 CORS 请求获取资源,因为字体可能来自不同的域。如果不设置此属性,浏览器将忽略预加载的字体

浏览器资源提示关键词详见:资源提示关键词

在 src 属性内前置 local()

local('Font Name') 放在 src 属性第一位,确保在本地已经安装了对应字体时不需要再进行网络请求

@font-face {
  font-family: 'MyFont';
  font-display: auto;
  src: local('MyFont'), url(https://font.com/myFont.woff2) format('woff2');
}

使用 FontFace 和 FontFaceSet

异步加载字体:通过 Web Font Loader 实现字体的异步加载,即使字体未完全加载,也不会阻塞页面的初始渲染。字体加载完成后,文本内容会自动更新为指定字体

  • FontFace:提供 JS 接口来定义和操作 CSS 字体、跟踪它们的下载进度,并覆盖它们的默认延迟加载行为,可以理解成下载字体的 fetch 方法。例如,如果确定需要特定的字体变体,可以定义它并告诉浏览器立即启动字体资源的获取
  • FontFaceSet:消费 FontFace 下载的字体并查询字体下载状态
属性 / 方法用途示例
ready查询字体加载是否完成const isReady = await document.fonts.ready;
size查询当前已添加的 FontFace 数目console.log(document.fonts.size); // 1
status查询当前 FontFaceSet 内添加的 FontFace 状态console.log(document.fonts.status) // 'loaded'
add()添加 FontFace 到 FontFaceSetconst font = new FontFace("MyFont", "url(myFont.woff2)");
check()检查能否以特定字体渲染文本,可用来检测字体是否已加载document.fonts.check("12px MyFont","ß") // 如果字体“MyFont”具有 ß 字符,则返回 true
const font = new FontFace('Arvo', 'url(https://fonts.gstatic.com/s/arvo/v9/rC7kKhY-eUDY-ucISTIf5PesZW2xOQ-xsNqO47m55DA.woff2)', {
  style: 'normal', unicodeRange: 'U+000-5FF', weight: '400',
})
 
font.load().then(function () {
  // 方案1: 字体加载完成后应用字体到 body 上 (可能重新渲染文本并会造成页面回流)
  document.fonts.add(font)
  document.body.style.fontFamily = 'Arvo, serif'
 
  // FontFaceSet API
  console.log('size: ', document.fonts.size) // 1, 当前已添加的 font 数量
  console.log('status: ', document.fonts.status) // 'loaded'
 
  // 方案2: 默认文本不可见,字体加载完成后再设为可见并渲染内容
  const content = document.getElementById('content')
  content.style.visibility = 'visible'
 
  // 方案3: 自定义其他逻辑
 
})

字体传输

更快的字体传输可以帮助字体更快渲染。如果字体传输足够快,那就可以避免布局偏移和 FOIT

字体文件为什么那么大?

这里所说的字体体积大的资源多数是指中文字体,主要原因下边两点:

  • 中文字符数量庞大:英文仅 26 个字母 + 符号,中文(全字符集)包含 70,000+ 字符
  • 字形结构复杂:字体文件需为每个字符存储独立的矢量轮廓数据,而汉字笔画复杂,每个字符需存储数百个控制点坐标(例如「龍」字的轮廓点数量可能是「A」的 10 倍以上)

总结下来就是咱们不光汉字多,书法也是五花八门,它是真小不了。如果硬要压缩,只能从第一点入手,将字符数量进行缩减,比如保留 1000 个常用汉字

字体传输优化方案

服务器开启 gzip 压缩和 HTTP 缓存

字体文件一般不会进行修改,所以可以使用强缓存

浏览器缓存详见:浏览器缓存

使用 CDN

很明显,为了确保字体快速且正确地应用在我们网页上,必须让浏览器尽快下载字体文件。通过 CDN,字体文件会被缓存在全球各地的边缘节点上,用户请求时可以从最近的节点获取,显著降低加载延迟

使用 WOFF2 格式字体

Web 网站中常见字体格式:

格式说明优点缺点适用场景
WOFF2Web 专用格式,基于 Brotli 压缩算法✅ 压缩率最高(比 WOFF 小 30%+)
✅ 现代浏览器广泛支持
✅ 支持子集化
❌ 不支持 IE11 及更旧浏览器现代浏览器优先
WOFFWeb 专用格式,基于 zlib 压缩算法✅ 兼容性好(IE9+)
✅ 体积较小
❌ 压缩率低于 WOFF2广泛兼容的备选方案
TTFTrueType 字体格式,未压缩的原始字体文件✅ 所有浏览器兼容
✅ 支持复杂字形
❌ 文件体积大
❌ 无压缩优化
备用格式/旧版浏览器兼容,广泛用于屏幕和打印
OTFOpenType 字体格式,扩展了 TTF 的排版功能✅ 支持高级排版(连字/替换字符)
✅ 矢量精度高
❌ 文件体积大
❌ 需手动压缩优化
专业排版需求(多语言/复杂样式)

这里通过思源黑体 给一个直观的对比:

  • TTF 文件:16.9 MB
  • WOFF2 文件:7.4 MB(压缩率约 60%)

两者为什么会差这么多,其实 WOFF2 只是在 TTF/OTF 基础上添加了压缩和 Web 专用元数据,且 WOFF2 支持增量解码,也就是边下载边解析,文本可更快显示(即使字体未完全加载,不过有待考证)

WOFF 字体在 2012 年 12 月被 World Wide Web Consortium (W3C) 推荐使用,IE9+ 浏览器支持。WOFF 2 字体最早在在 2013 年 7 月 Chrome Canary 版本上可以使用,发展到现在,几乎已经成为自定义图标字体使用的标配,目前浏览器的兼容性已经相当不错了

WOFF 2 标准在 WOFF 的基础上,进一步优化了体积压缩,带宽需求更少,同时可以在移动设备上快速解压。与 WOFF 中使用的 Flate 压缩相比,WOFF 2 是使用 Brotli 方法进行的压缩,压缩率更高,所以文件体积更小

将字体转为 Base64

将字体作为 Base64 字符串嵌入到 CSS 中,从而无需额外的字体请求并确保在呈现文本时字体可用。但这个方法也不是绝对的好方法,它只适合一些小型字体文件,因为将字体文件转化为 Base64 字符串往往会增加体积

提取子集

除了以上方案,最能够立竿见影的主要下边这两个方案:

方案方法/原理适用场景
提取子集通过工具将字体文件进行提取(支持动态),返回指定的字符集的字体文件,其根本就是减少单次资源请求的体积,需要服务端支持这个方案是所有优化场景的基础
字体切片通过设置 unicode-range 属性,浏览器在进行 CSS 样式计算时候,会根据页面中的字符与设置的字符范围进行比对,匹配上会加载对应的字体文件前提是资源已经被子集化,比较适用多语言切换的场景

简单来说,提取子集可单独食用,字体切片则必须要将字体前置子集化,才能完美实现按需加载

两个方案各有各的适用空间:

  • 提取子集可以实现极致的体积压缩,在仅需展示少量字体的情况下效果显著
  • 字体切片可以在支持所有字体的情况下,以较小的加载体积呈现页面

前端实现

把系统内未使用过的汉字裁掉,只保留系统内使用的汉字,这样就可以大大减少字体文件体积。但是这种方式只适用于固定内容的网页,像一些动态内容(服务端返回的汉字,评论等)就没法适应

后端实现

这里使用 Python 中的一个字体工具库 fontTools 来实现一个动态子集化,类似于 Google Fonts 的实现。核心思路就是将字符传给服务端,通过工具将传入的字符在本地字体文件中提取并返回给客户端,通过 fontTools 还可以将 TTF 格式转化为和 Web 更搭的 WOFF2 格式。实现细节如下述代码所示:

@app.route('/font/<font_name>', methods=['GET'])
def get_font_subset(font_name):
    # 获取本地字体文件路径
    font_path = os.path.join(FONTS_DIR, f"{font_name}.ttf")
    # 获取子集字符
    chars = request.args.get('text', '')
    # 字体文件格式
    format = request.args.get('format', 'woff2').lower()
 
    # 处理字符,去重
    unique_chars = ''.join(sorted(set(chars)))
    try:
        # 配置子集化选项
        options = Options()
        options.flavor = format if format in {'woff', 'woff2'} else None
        options.desubroutinize = True  # 增强兼容性
        subsetter = Subsetter(options=options)
        
        # 加载字体并生成子集
        font = TTFont(font_path)
        subsetter.populate(text=unique_chars)
        subsetter.subset(font)
 
        # 保存为指定格式
        buffer = io.BytesIO()
        font.save(buffer)
        buffer.seek(0)
 
        # 确定MIME类型
        mime_type = {
            'woff2': 'font/woff2',
            'woff': 'font/woff',
        }[format]
 
        # 创建响应并设置
        response = Response(buffer.read(), mimetype=mime_type)
        # 其他设置...
        return response
 
    except Exception as e:
        # 子集化失败...

前端代码中增加了一些字符提取的工作,通过 FontFace API 来请求字体资源的,所以仅需将资源链接替换为子集化字体的接口就可以了,下面代码来描述字体的加载过程:

// ...其他逻辑
Toast.loading('字体加载中')
// 遍历海报中的字体对象
[...new Set(fontFamilies)].forEach((fontName) => {
  // 在字体库中找到对应字体详细信息
  const obj = fontLibrary.find((el) => el?.value === fontName) ?? {};
 
  if (obj.value && obj.src) {
    // 处理海报中提取的文案集合
    const text = textMap[obj.value].join('');
    // 构建字体对象
    const font = new FontFace(
      obj.value,
      `url(http://127.0.0.1:5000/font/${obj.value}?text=${text}&format=woff2)`
    );
    // 加载字体
    font.load();
    // 添加到文档字体集中
    document.fonts.add(font);
  }
});
// 文档所有字体加载完毕后返回成功的 Promise
return document.fonts.ready.finally(() => Toast.destory());

字体切片

Github 地址:https://github.com/voderl/font-slice

使用

  1. 安装
npm install --save-dev font-slice
yarn add -D font-slice
  1. 编写脚本
const createFontSlice = require('font-slice');
 
createFontSlice({
  // fontPath
  fontPath: path.resolve(__dirname, 'YourPath.ttf'),
  // outputDir
  outputDir: path.resolve(__dirname, './output'),
  fontFamily: 'HarmonyOS_SansSC',
  // 是否需要在生成完成后打开预览页面,默认为 true,如果为 false 不会生成 index.html 及启动服务器
  preview: false,
})

可能等待时间较长,请耐心等待,完成后可以直接预览字体

  1. 引用生成的 font.css 文件,设置对应的 fontFamily 即可

运行完成后,会输出 font.css 和切割成多份的字体文件。将 CSS 引入页面。页面内的地方就可以正常使用 font-family 了

也可以将生成的产物部署到 CDN 上,直接引用 CDN 的地址

注意项:

  • 默认的 font-display 为 swap,即在字体没有加载完成时,先使用别的字体展示(会有一瞬间的文字闪动)。需要调整的话可以在传入的 options 里指明。如果设置为 block,当字体没有加载完成时,会在一定的时间里不展示对应的内容。 更多 font-display 介绍请看这里
  • 同时建议在 CDN 中将对应的字体目录直接设置一定时长的浏览器缓存,避免因字体加载导致页面内容闪动
  • 如果在 canvas 中使用,需要先加载文案对应的字体子集再去渲染
// 字体引入 css 文件需要先加载完成
document.fonts.load(`14px ${fontFamily}`, '指定文案').then(() => {
    ctx.fillText('指定文案');
});

效果

得意黑 字体为例为例:

处理前 TTF 大小 2074KB,WOFF2 大小 928KB;处理后每个类型的字体生成 95 个文件:

  • TTF   总大小为 2.3M(最小文件 3.4K,最大文件 55K)
  • WOFF2 总大小为 1.3M(最小文件 1.5K,最大文件 33K)

实际加载页面的体积由页面使用的字符决定,以该页面为例,只需要加载 386KB 就能覆盖全部字符

原理

以上的效果是怎么做到的呢?

unicode-range 定义每个资源支持的一组 Unicode 字符。这样就能将大型 Unicode 字体拆分成较小的子集(例如,中文、拉丁文和希腊文子集),并且仅需要在页面上下载呈现文本所需的字体集合

@font-face {
    font-family: "Open Sans";
    src: url("/fonts/OpenSans-Regular-webfont.woff2") format("woff2");
    unicode-range: U+0025-00FF;
}

Unicode 范围描述符 可以指定通过逗号分隔的多个字符范围值,每个范围值都可以采用以下三种形式之一:

  • 单个代码点(例如,U+416
  • 区间范围(例如,U+400-4ff):表示范围的开始和结束代码点
  • 通配符范围(例如,U+4??):? 字符表示任何十六进制数字

谷歌字体等字体提供商可以提供部分中文字体的解决方案。首先,它们有着庞大的 CDN 网络,在传输过程中可以使用 gzip 等压缩方案,能够让世界各地的人以较快的速度加载字体。其次,它们采用多种优化手段,比如按照使用频率来分成不同字体包来减小加载体积

02/05/2019 from https://design.google/news/google-fonts-launches-chinese-support/

Google Fonts launches Simplified and Traditional Chinese support

New year—new, faster fonts. In the spirit of the Lunar New Year, the Google Fonts catalog now includes five Simplified and two Traditional Chinese fonts—the Chinese written language differs according to country—for designers and developers working with Chinese text. Since Chinese fonts often contain more than 10,000 characters, single font file delivery is unacceptably slow. Building on earlier launches for Korean and Japanese, Google Fonts has analyzed character usage over millions of public web pages to build optimized font ”slicing” patterns for both Simplified and Traditional Chinese. This allows modern web browsers to only download the portions of a font—typically a very small fraction of the complete set—containing the characters that they need.

Head over to Google Fonts to check out—and try out—the Simplified Chinese and Traditional Chinese libraries.

Google 字体中引入字体的示例 CSS:

...
/* [4] */
@font-face {
  font-family: 'Noto Sans SC';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosanssc/v12/k3kXo84MPvpLmixcA63oeALhLOCT-xWNm8Hqd37g1OkDRZe7lR4sg1IzSy-MNbE9VH8V.4.woff2) format('woff2');
  unicode-range: U+1f1e9-1f1f5, U+1f1f7-1f1ff, U+1f21a, U+1f232, U+1f234-1f237, U+1f250-1f251, U+1f300, U+1f302-1f308, U+1f30a-1f311, U+1f315, U+1f319-1f320, U+1f324, U+1f327, U+1f32a, U+1f32c-1f32d, U+1f330-1f357, U+1f359-1f37e;
}
/* [5] */
@font-face {
  font-family: 'Noto Sans SC';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(https://fonts.gstatic.com/s/notosanssc/v12/k3kXo84MPvpLmixcA63oeALhLOCT-xWNm8Hqd37g1OkDRZe7lR4sg1IzSy-MNbE9VH8V.5.woff2) format('woff2');
  unicode-range: U+fee3, U+fef3, U+ff03-ff04, U+ff07, U+ff0a, U+ff17-ff19, U+ff1c-ff1d, U+ff20-ff3a, U+ff3c, U+ff3e-ff5b, U+ff5d, U+ff61-ff65, U+ff67-ff6a, U+ff6c, U+ff6f-ff78, U+ff7a-ff7d, U+ff80-ff84, U+ff86, U+ff89-ff8e, U+ff92, U+ff97-ff9b, U+ff9d-ff9f, U+ffe0-ffe4, U+ffe6, U+ffe9, U+ffeb, U+ffed, U+fffc, U+1f004, U+1f170-1f171, U+1f192-1f195, U+1f198-1f19a, U+1f1e6-1f1e8;
}
...

即:按照一定的粒度,将字体分成多个文件,比如一个 4MB 的字体包分成 100 个 40KB 的字体包。通过机器学习等方法,将一些字频较高的字体、容易同时出现的字体(词语、成语、诗句等)分别打包进同一个字体包,并通过 CSS 中 unicode-range 来给不同文字加载不同的字体包资源。这样的话,一般网页中使用到的中文也只是一部分字体,只需要加载多个资源包就能完全覆盖。同时,就算网页中有很多生僻字,需要付出的代价也只是多加载几个资源包

font-slice 也是同样的原理,所做的内容如下:

  1. 提取 google fonts 的 unicode-range
  2. 提取要处理的字体包含的所有字符,得到 google fonts 的 unicode-range 和字体里包含的字符的交集部分
  3. 将字符按照上面步骤得出的拆分方案,提取字体子集,生成多个文件及 CSS 样式文件

字体渲染

可以先阅读 网页字体度量及渲染 了解字体度量,帮助理解本章节

font-display 介绍

字体显示时间线

字体显示时间线基于一个计时器,该计时器在用户代理尝试使用给定下载字体的那一刻开始。时间线分为三个时间段,在这三个时间段中指定使用字体的元素的渲染行为

  • 字体阻塞周期:如果未加载字体,任何试图使用它的元素都必须渲染不可见的后备字体。如果在此期间字体已成功加载,则正常使用它
  • 字体交换周期:如果未加载字体,任何尝试使用它的元素都必须呈现后备字体。如果在此期间字体已成功加载,则正常使用它。
  • 字体失败周期:如果未加载字体,用户代理将其视为导致正常字体回退的失败加载

font-display 对应属性

以下表格中的阻塞时长是 W3C 提案中推荐值,各浏览器实现可能有差异

属性阻塞时长交换时长介绍
auto浏览器默认浏览器默认字体显示策略由用户代理(浏览器各自默认行为)
block3s无限为字体提供一个短暂的阻塞周期和无限的交换周期。等待字体时隐藏文本最多 3 秒,字体加载完成时交换
swap100ms 或更少无限为字体提供一个非常小的阻塞周期和无限的交换周期。尽快显示文本,字体加载完成时交换
fallback100ms 或更少3 秒为字体提供一个非常小的阻塞周期和短暂的交换周期。隐藏文本最多 100 毫秒, 字体 3 秒内加载完成时交换,超过三秒保持展示后备字体
optional文本初次渲染前的时长不阻塞文本渲染,并且没有交换周期。字体在文本首次渲染前加载完成则展示字体,否则展示后备字体,从不交换

==注意:下图是很多博文的配图,和 W3C 的规范 及浏览器实现是有出入的==

字体渲染优化方案

选择合适的 font-display 策略

不同的 font-display 策略需要在页面性能和样式之间进行平衡。因此,很难给出推荐的方法,因为它确实取决于个人偏好、Web 字体对页面和品牌的重要性,以及字体延迟切换带来突兀的用户体验

对于绝大多数页面,基本都适用于以下三种方案:

  1. 性能优先:使用 font-display: optional;optional 是唯一保证不发生布局偏移的字体显示值。文本渲染几乎无延迟。并且确保不会发生因为字体切换造成的布局偏移。但缺点也很明显,如果文本首次渲染前字体未加载完成,后备字体可能样式并不满足 UI 要求
  2. 需要文本尽快展示且需要确保使用 Web 字体:使用 font-display: swap;,swap 确保了文本展示几乎无延迟,但可能由于字体加载慢导致布局偏移。所以需要尽量让字体尽快加载完成
  3. 确保文本展示使用 Web 字体:使用 font-display: block;,block 让文本有 3s 的不可见时间,web 字体基本可以在 3s 内加载完成,这样文本展示的时候就会自动使用 web 字体。但仍然存在由于 web 字体加载慢导致布局偏移的问题,同时文本 3s 的不可见时间也是一个负面影响

使用 size-adjust 防止布局偏移

size-adjust CSS 描述符为与此字体关联的字形轮廓和指标定义乘数。这使得在以相同字体大小呈现时更容易协调不同字体的设计

如何使用:

@font-face {
  font-family: "MyFont";
  size-adjust: 150%; // 相对 MyFont 字体默认大小,调整到 150%
  src: url(some/path/to/myFont.woff2) format('woff2');
}

size-adjust 是等比放大了字体度量的所有参数:Ascent / Descent / Line Gap 均被放大 150%

这个方案也存在很明显的问题,不同字体之间切换的 size-adjust 百分比需要手动调试计算。浏览器目前的兼容性 也很一般

更精细的字体调整防止布局偏移

使用 ascent-overridedescent-overrideline-gap-override 属性来调整字体。这 3 个 CSS 属性作用都是类似的,都是在 @font-face 自定义字体中设置文字的上、中或下间隙大小

W3C 文档 中的介绍并不明确,具体计算方式见下表。Ascent / Descent / Line Gap / em 的取值见:网页字体度量及渲染

属性描述介绍
ascent-override设置上悬线距离基线的距离normal默认值,由字体文件决定:(Ascent / em) * font-size。初始值为 Ascent / Em Size
<percentage>范围从 0% - ∞,值越大,文字位置越低。具体值大小为:percentage * font-size
descent-override设置下悬线距离基线的距离normal默认值,由字体文件决定:(Descent / em) * font-size。初始值为 Descent / Em Size
<percentage>范围从 0% - ∞,值越大,文字位置越高。具体值大小为:percentage * font-size
line-gap-override设置行间距 > 行间距 = 行高 - 字体大小> 行高:line-height 为 normal 时的行高 > 字体大小:每个字符的内容高度(不是 font-size)normal默认值,由字体文件决定:(Line Gap / em) * font-size。初始值为 Line Gap / Em Size
<percentage>范围从 0% - ∞,值越大,行间隙越大。具体值大小为:percentage * font-size

下面以 Catamaran 字体为例(为了展示 Line Gap,此处手动将 Catamaran 字体的 HHead Line Gap 值从 0 调整为 500)。由下图可得知 Catamaran 字体的各个参数(重点关注 110%、54%、50% 这三个值):

  • Ascent: 1100
  • Descent: 540
  • Line Gap: 500
  • Em Size: 1000
  • ascent-override: normal = Ascent / Em Size = 110%
  • descent-override: normal = Descent / Em Size = 54%
  • line-gap-override: normal = Line Gap / Em Size = 50%

下面由几个示例来说明这三个 CSS 属性如何使用。示例中,line-height 值为 normal ,红色文字为初始文字位置,蓝色文字为调整后文字位置,浅灰色为文本内容高度,深灰色为半行距

ascent-override

ascent-override 值越大文字行高越大

示例 1:ascent-override: 0%

由下图可见,ascent-override 设为 0 时,文字的 Ascent 部分的高度完全没有了,行高塌陷了一大部分。但因为文字基线位置不变,所以相比于自己的内联行盒,文字整体朝上移动了

@font-face {
  font-family: "Catamaran";
  src: local(Catamaran_GAP500.woff2);
  ascent-override: 0%;
}

示例 2:ascent-override: 110%

由下图可见,ascent-override 设为 110% 时,文字的 Ascent 部分恢复成了默认高度

@font-face {
  font-family: "Catamaran";
  src: local(Catamaran_GAP500.woff2);
  ascent-override: 110%;
}

descent-override

descent-override 值越大文字行高越大

示例 1:descent-override: 0%

由下图可见,descent-override 设为 0 时,文字的 Descent 部分的高度完全没有了,行高塌陷了一大部分。但因为文字基线位置不变,所以相比于自己的内联行盒,文字整体朝下移动了

@font-face {
  font-family: "Catamaran";
  src: local(Catamaran_GAP500.woff2);
  descent-override: 0%;
}

示例 2:descent-override: 54%

由下图可见,descent-override 设为 54% 时,文字的 Descent 部分恢复成了默认高度

@font-face {
  font-family: "Catamaran";
  src: local(Catamaran_GAP500.woff2);
  descent-override: 54%;
}

line-gap-override

line-gap-override 属性的作用是设置这个字体的行间距(行间距 = 行高 - 字体大小)

line-gap-override 设置要想生效,则对应字体所在的 lien-height 属性值必须是 normal,无论是数值,长度值还是百分比值都会让 line-gap-override 属性没有效果(只有当行高为 normal 时,最终的行高大小才由字体决定)

line-gap-override 值越大文字行高越大

示例 1:line-gap-override: 0%

由下图可见,line-gap-override 设为 0 时,绿色文字的 Line Gap 部分的高度完全没有了,行高上下均塌陷了一部分。但因为文字基线位置不变,所以相比于自己的内联行盒,文字未移动

@font-face {
  font-family: "Catamaran";
  src: local(Catamaran_GAP500.woff2);
  line-gap-override: 0%;
}

示例 2:line-gap-override: 50%

由下图可见,line-gap-override 设为 50% 时,绿色文字的 Line Gap 部分恢复成了默认高度

@font-face {
  font-family: "Catamaran";
  src: local(Catamaran_GAP500.woff2);
  line-gap-override: 50%;
}

多字体实战

示例使用的两种字体对应的字体度量参数如下表

属性AscentDescentLine GapEm SizeFont Size
Catamaran11005405001000100px
MILanPro104428201000100px

如下图为未使用任何 CSS 参数调整字体的示例,红色文字字体为 Catamaran,绿色文字字体为 MILanPro,绿色文字行高明显比红色文字小。如果 Catamaran 为 Web 字体,MILanPro 为后备字体,那么在从 MILanPro 切换为 Catamaran 时,就会发生布局偏移

@font-face {
	font-family: Catamaran_GAP500;
	src: url("Catamaran_GAP500.woff2") format("woff2");
}
 
@font-face {
	font-family: MILanPro;
	src: url("MILanPro.woff2") format("woff2");
}

将 MILanPro 字体 ascent-overridedescent-overrideline-gap-override 分别调整为 Catamaran 字体的默认值,如下图为调整了CSS 参数后的渲染结果。虽然从字母 i(字母 i 上的点,红色为圆形,绿色为方形)可以明显看出两种字体渲染的文字样式的区别,但最终渲染文字的 Ascent / Descent / LineGap 尺寸完全一致

@font-face {
  font-family: Catamaran_GAP500;
  src: url("Catamaran_GAP500.woff2") format("woff2");
}
 
@font-face {
  font-family: MILanPro;
  src: url("MILanPro.woff2") format("woff2");
  ascent-override: 110%; // Catamaran_GAP500 的默认值
  descent-override: 54%; // Catamaran_GAP500 的默认值
  line-gap-override: 50%; // Catamaran_GAP500 的默认值
}

注意,Ascent / Descent / LineGap 尺寸完全一致只是保证了两种字体的默认行高(line-height)完全一致,但不同字体相同字符的宽度不一定是一致的。下表为大写字母 X 在 Catamaran 和 MILanPro 字体中的宽度:

大写字母 X宽度
Catamaran588
MILanPro691

如上图所示,红色为 Catamaran 字体,绿色为 MILanPro 字体,已经调整了 MILanPro 字体的度量参数,使两种字体的度量参数(Ascent / Descent / LineGap)完全一致。最终不同字体相同文本的宽度明显不一致