4、性能指标定义:FCP、LCP、CLS、字体交换时间(FOUT/FOIT)的测量方法

聊字体渲染性能,不能光凭感觉说「这个字体加载慢」或者「页面闪了一下」。得用数据说话。我刚开始做性能优化时,也踩过这个坑——凭肉眼判断,结果上线后用户反馈完全不一样。

这一章,我们就把几个核心指标掰开揉碎讲清楚。FCP、LCP、CLS 是 Google 的 Web Vitals 三件套,而 FOUT 和 FOIT 是字体领域特有的「隐形杀手」。测量方法我会给出具体工具和代码,你照着做就行。

4.1 核心指标速览

先给个总览表格,方便你快速对照:

指标 全称 衡量什么 字体相关影响
FCP First Contentful Paint 首次内容绘制时间 字体阻塞渲染时,FCP 会延迟
LCP Largest Contentful Paint 最大内容绘制时间 标题/正文使用 Web 字体时,LCP 可能被拖后
CLS Cumulative Layout Shift 累计布局偏移 FOUT 导致的文字重排是 CLS 的常见来源
FOUT Flash of Unstyled Text 无样式文本闪烁 回退字体先显示,Web 字体加载后替换
FOIT Flash of Invisible Text 不可见文本闪烁 字体加载期间文本完全不可见

关键认知:FCP 和 LCP 是「时间维度」的指标,CLS 是「稳定性维度」的指标。FOUT 和 FOIT 则是字体加载策略直接导致的「视觉体验」问题。它们之间会互相影响——FOIT 会让 FCP 变差,FOUT 会让 CLS 飙升。

4.2 FCP(首次内容绘制)

FCP 记录的是浏览器渲染第一个文本或图像的时间点。说白了,就是用户看到「页面上有东西了」的那一刻。

测量方法:

  • Chrome DevTools Performance 面板:录制页面加载,在 Timings 区域直接看 FCP 标记。
  • PerformanceObserver API:用代码实时捕获。
// 用 PerformanceObserver 测量 FCP
new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  entries.forEach(entry => {
    console.log(`FCP: ${entry.startTime}ms`);
  });
}).observe({ type: 'paint', buffered: true });

我的经验:如果页面首屏用了一个需要 3 秒才能加载的 Web 字体,FCP 会被卡到 3 秒以后。这时候就算 DOM 解析完了,用户也看不到任何文字。我建议在字体加载完成前,先用系统回退字体渲染文本,这样 FCP 能提前到 1 秒以内。

4.3 LCP(最大内容绘制)

LCP 衡量的是视口内「最大可见元素」完成渲染的时间。这个元素通常是标题图片、视频封面,或者——没错,就是大号标题文字。

测量方法:

  • Lighthouse 报告:直接给出 LCP 分数和建议。
  • PerformanceObserver API:监听 largest-contentful-paint 类型。
// 测量 LCP
new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log(`LCP: ${lastEntry.startTime}ms`);
}).observe({ type: 'largest-contentful-paint', buffered: true });

注意:LCP 只报告一次,但浏览器会持续更新直到用户交互。如果字体加载导致页面布局变化,LCP 元素可能从「小标题」变成「大标题」,时间点会往后跳。我曾经在一个博客项目中遇到过,LCP 从 1.2 秒跳到了 4.5 秒,就是因为字体加载后标题字号变大了。

4.4 CLS(累计布局偏移)

CLS 衡量的是页面在加载过程中「意外移动」的总量。字体加载导致的文字重排,是 CLS 的经典触发场景。

测量方法:

  • Chrome DevTools:在 Performance 面板勾选「Layout Shifts」。
  • PerformanceObserver API:监听 layout-shift 类型。
// 测量 CLS
let clsValue = 0;
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    if (!entry.hadRecentInput) {
      clsValue += entry.value;
    }
  }
  console.log(`CLS: ${clsValue}`);
}).observe({ type: 'layout-shift', buffered: true });

避坑指南:我曾经在一个电商项目中,因为字体加载后宽度变窄,导致所有商品卡片都向左偏移了 20 像素。CLS 直接飙到 0.45。解决方案是在 @font-face 中设置 size-adjust 属性,让回退字体和 Web 字体的度量尽可能一致。

4.5 FOUT 与 FOIT 的测量

这两个指标不在 Web Vitals 标准里,但做字体优化必须关注。它们直接决定了用户「看到文字」的体验。

4.5.1 FOUT(无样式文本闪烁)

FOUT 发生在 font-display: swap 策略下。浏览器先用回退字体渲染文本,等 Web 字体加载完成后,再替换成目标字体。这个「替换」过程就是闪烁。

测量方法:

  • Performance Observer + 自定义标记:在字体加载前后记录时间戳。
  • CSS Font Loading API:监听字体加载状态。
// 测量 FOUT 持续时间
document.fonts.ready.then(() => {
  // 所有字体加载完成
  performance.mark('font-loaded');
});

// 在页面渲染时标记
performance.mark('font-start');

// 计算差值
performance.measure('fout-duration', 'font-start', 'font-loaded');
const measures = performance.getEntriesByName('fout-duration');
console.log(`FOUT 持续时间: ${measures[0].duration}ms`);

4.5.2 FOIT(不可见文本闪烁)

FOIT 发生在 font-display: block 策略下。浏览器会隐藏文本,直到字体加载完成。这段时间用户什么都看不到。

测量方法:

  • 肉眼观察 + 视频录制:最原始但有效的方法。
  • Performance Observer + 元素可见性检测:检查文本节点的 opacity 或 visibility 变化。
// 检测 FOIT 时长(简化版)
const textElement = document.querySelector('.title');
const observer = new MutationObserver(() => {
  if (textElement.style.visibility !== 'hidden') {
    performance.mark('text-visible');
    observer.disconnect();
  }
});
observer.observe(textElement, { attributes: true, attributeFilter: ['style'] });

我的建议:FOIT 对用户体验的伤害比 FOUT 更大。用户看不到文字,会以为页面卡死了。我一般推荐用 font-display: swap 配合 size-adjust,把 FOUT 的视觉影响降到最低。

4.6 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到 FCP、LCP、CLS 与 FOUT/FOIT 之间的关联,以及它们如何共同影响字体渲染性能。

字体渲染性能指标关系图 字体渲染性能指标关系图 字体加载 font-display: swap font-display: block FOUT(文本闪烁) FOIT(文本隐藏) FCP 延迟 CLS 飙升 LCP 受影响 说明:swap 策略导致 FOUT(文本闪烁),block 策略导致 FOIT(文本隐藏)。 两者都会影响 FCP、LCP 和 CLS,但影响方式和程度不同。

4.7 综合测量方案

在实际项目中,我建议你把这几个指标放在一起测量。下面是一个综合脚本的骨架:

// 综合性能指标采集
const metrics = {};

// FCP
new PerformanceObserver((list) => {
  metrics.fcp = list.getEntries()[0].startTime;
}).observe({ type: 'paint', buffered: true });

// LCP
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  metrics.lcp = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });

// CLS
let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) cls += entry.value;
  }
  metrics.cls = cls;
}).observe({ type: 'layout-shift', buffered: true });

// 字体加载时间
document.fonts.ready.then(() => {
  metrics.fontReady = performance.now();
  console.log('所有指标:', metrics);
});

重要提醒:这些测量最好在真实用户环境(RUM)中采集,而不是只在本地开发环境跑。因为字体加载时间受网络、缓存、设备性能影响很大。我习惯用 PerformanceObserver 配合 RUM 工具(比如 Google Analytics 的 Web Vitals 插件)来收集线上数据。