性能在前端领域的重要性已经是老生常谈了,比如早在 2012 年,亚马逊就发布过一个研究结果:页面加载速度一旦下降一秒,每年就会损失 16 亿美元的销售额。那这个一秒又是怎么统计的呢?

同样是在 2012 年, Web 性能工作组针对页面加载场景制定了一个加载过程模型,用来衡量页面加载各个阶段的耗时情况,相信这张图大家应该都见过:

W3C 性能模型
W3C 性能模型

但是随着时代的发展,图中的指标已经不太能准确的反映性能表现了,比如在一个 SPA 应用中,页面容器的 DOM 结构已经生成了,但实际上当前页面还是一个白屏的空壳、又比如页面中绝大部分内容已经渲染好了,但是有一张特别大的图片还在加载中…

针对这些问题,浏览器厂商、web 工作组等都提出了一些关键指标,比如谷歌提出了 4 个衡量用户体验的新指标(LCP、CLS、FID、INP),这 4 个指标将会影响谷歌搜索引擎的排名,Chrome 开发团队也提出了 4 个指标(FP、FCP、LCP、CLS),W3C 性能工作组则认为 LCP、CLS 和 TBT 是最值得关注的。

在现代前端性能监控系统中,我们也经常会见到它们,我们对页面性能的衡量,基本就是通过对这些指标的收集和计算得来的。有了这些指标,我们也可以有效的还原出页面加载的瀑布图、火焰图等,从而更有针对性的做出优化。

例如,这是一张火山引擎前端监控系统的截图:

火山引擎前端监控
火山引擎前端监控

在浏览器的 devtools 中,我们也可以通过 performance 来统计一次加载过程中的一些指标:

Chrome Devtools
Chrome Devtools

下面我们就来一起看一下这些指标的具体含义:

渲染相关

TTFB(Time to First Byte )

TTFB 是资源请求与响应的第一个字节开始到达之间的时间,如下图所示,TTFB 是 startTime 和 responseStart 之间经过的时间。

我们可以使用 Google 提供的 web-vitals 这个库来测量 TTFB 时间。同样,后文中的 CLS、FCP、FID、INP、LCP 时间也都可以通过这个库来获取。使用方式可以参考如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { onTTFB, onCLS, onFID, onLCP } from "web-vitals";

// Measure and log TTFB as soon as it's available.
onTTFB(console.log);

function sendToAnalytics(metric) {
const body = JSON.stringify(metric);
// Use `navigator.sendBeacon()` if available, falling back to `fetch()`.
(navigator.sendBeacon && navigator.sendBeacon("/analytics", body)) ||
fetch("/analytics", { body, method: "POST", keepalive: true });
}

onCLS(sendToAnalytics);
onFID(sendToAnalytics);
onLCP(sendToAnalytics);

如果希望能自己通过底层 Web API 直接衡量(可能兼容性会弱于web-vitals),可以这样实现:

1
2
3
4
5
6
7
8
new PerformanceObserver((entryList) => {
const [pageNav] = entryList.getEntriesByType("navigation");

console.log(`TTFB: ${pageNav.responseStart}`);
}).observe({
type: "navigation",
buffered: true,
});

FP(First Paint)

FP 代表浏览器第一次向屏幕传输像素的时间,也就是页面在屏幕上首次发生视觉变化的时间。在 FP 时间点之前,用户看到的都是没有任何内容的白色屏幕。在现代前端应用中,如前文所述, FP 已经不能准确反映页面性能了,目前 chrome、Lighthouse 等都已经不再统计 FP 了。

获取方式

1
2
3
4
5
performance
.getEntriesByType("paint")
.filter((item) => item.name === "first-paint")[0].startTime;

// 1376.300000011921

FCP(First Contentful Paint)

FCP 用于测量从网页开始加载到网页任何一部分内容呈现在屏幕上的时间。“内容”是指文本、图片(包括背景图片)、svg 元素或非白色 canvas 元素。

如下图中,第二帧的时间,就是 FCP 时间。

FCP
FCP

获取方式

1
2
3
4
5
performance
.getEntriesByType("paint")
.filter((item) => item.name === "first-contentful-paint")[0].startTime;

// 1376.300000011921

FMP(First Meaningful Paint)

FMP 用来测量页面的主要内容何时对用户可见。由于对“主要内容”的判定很复杂,所以大部分情况下,FMP 是通过一些猜测算法得到的。具体实现方式可以参考 这篇论文

FMP 和 FCP 最主要的区别在于 FMP 测量的是“有意义的内容”绘制的时间,但是这个指标的测量依赖于浏览器的实现细节,同时会受到多种因素影响引起剧烈波动,目前已经基本被弃用了,取而代之的是 LCP

SI

SI 是 Lighthouse 提出的一个指标,用于衡量页面加载期间内容视觉显示的速度。
SI 是通过 speedline 计算得到的。

LCP(Largest Contentful Paint)

由于 FMP 和 SI 的实现非常复杂,同时难以解释,而且往往是错误的,这意味着它们仍然无法确定页面主要内容的加载时间。根据 W3C Web 性能工作组中的讨论和 Google 的研究,我们发现,要衡量网页主要内容的加载时间,更为准确的方法是查看最大元素的呈现时间,LCP 应运而生。
LCP 是指视口内可见的最大图片或文本块的呈现时间,包括以下元素:

  • img 元素
  • svg 元素内的 image 元素
  • 包含海报图片的 video 元素(系统会使用海报图片加载时间)
  • 一个元素,带有通过 url() 函数(而不是 CSS 渐变)加载的背景图片
  • 包含文本节点或其他内嵌级别文本元素的子项的块级元素。
  • 为自动播放 video 元素而绘制的第一帧(截至 2023 年 8 月
  • 动画图片格式(例如 GIF 动画)的第一帧(截至 2023 年 8 月

同时,浏览器还会通过自己的启发式算法来排除一些元素,比如在 Chromium 中,下列元素会被排除:

  • 不透明度为 0 且对用户不可见的元素
  • 覆盖整个视口的元素,很可能被视为背景而非内容
  • 占位符图片或其他低熵的图片,可能无法反映网页真实内容

下图可以参考 LCP 时间:

LCP
LCP

获取方式

可以如前文所述,使用 web-vitals 获取。

如果希望自己获取,可以这样使用 Largest Contentful Paint API

1
2
3
4
5
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log("LCP candidate:", entry.startTime, entry);
}
}).observe({ type: "largest-contentful-paint", buffered: true });

但是实际应用中 LCP 的测量远比这里复杂,详细信息可以参考这篇文章

交互相关

TTI (Time to Interactive)

TTI 是指从网页开始加载到主要子资源已加载且能够快速可靠地响应用户输入的时间。

TTI 的计算方式如下:

  1. 从 First Contentful Paint (FCP) 开始。
  2. 向前搜索一个至少 5 秒的静默期,其中静默期的定义为:没有 Long Task,以及不超过两个正在进行的网络 GET 请求。
  3. 向后搜索静默窗口之前的最后一个长任务,如果找不到 Long Task,则在 FCP 处停止搜索。
  4. TTI 是静默期之前的最后一个长任务的结束时间(如果未找到 Long Task,则与 FCP 相同)。
TTI
TTI

TBT(Total Blocking Time)

TBT 表示从 FCP 到 TTI 之间,所有 Long Task 的阻塞时间之和。

每当存在 Long Task 时,主线程就会被视为“阻塞”。我们称主线程“阻塞”,因为浏览器无法中断正在进行的任务。因此,如果用户在执行耗时较长的任务时与网页互动,浏览器必须等待任务完成才能响应。

如果任务的时间足够长(超过 50 毫秒的任何任务),用户很可能会注意到延迟,并认为页面运行缓慢或卡顿。

TBT 就是在 FCP 和 TTI 之间发生的每个耗时较长任务的阻塞时间之和。

TBT
TBT

FID (First Input Delay)

FID 测量的是从用户第一次与网页互动(点击链接、点按按钮等操作)到浏览器实际能够开始处理事件处理脚本以响应该互动的时间。TTI 可以告诉我们网页什么时候可以开始流畅地响应用户的交互,但是如果用户在 TTI 的时间内,没有与网页产生交互,那么 TTI 其实是影响不到用户的,FID 则体现了真实的用户交互反馈。

FID
FID

获取方式

同样,web-vitals 也可以直接提供 FID。

我们也可以这样记录一条 first-input 数据:

1
2
3
4
5
6
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
const delay = entry.processingStart - entry.startTime;
console.log('FID candidate:', delay, entry);
}
}).observe({type: 'first-input', buffered: true});

其他

CLS (Cumulative Layout Shift)

CLS 衡量的是页面的整个生命周期内发生的每次意外布局偏移的最大突发性布局偏移量。

这句话可能有点难以理解,让我们来看一下下面这个场景:
我们正在一个购物网站中浏览,准备点击返回按钮,此时页面顶部忽然加载出了一条广告,导致页面布局发生了变化,造成误点了提交按钮,遇到这种情况,用户的心态应该是爆炸的。

CLS 可以帮我们统计出我们的页面上类似的布局偏移量,以便针对优化。

布局偏移由 Layout Instability API 定义,该 API 会在视口内可见的元素在两帧之间更改其起始位置(例如,在默认写入模式下的顶部和左侧位置)时,报告 layout-shift 条目。此类元素被视为不稳定的元素。
仅当现有元素更改其起始位置时,才会发生布局偏移。如果向 DOM 添加新元素或现有元素更改了尺寸,则不计为布局偏移,只要此类更改不会导致其他可见元素更改其起始位置即可。

获取方式

和前面的几个指标一样,CLS 也可以使用 web-vitals 获取。

我们也可以这样记录一条布局偏移数据:

1
2
3
4
5
6
// 最终的 CLS 数据应该由贯穿网页生命周期的多条数据计算得出
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log("Layout shift:", entry);
}
}).observe({ type: "layout-shift", buffered: true });

Long Task

Long Task 是指在主线程上运行达 50 毫秒或以上的任务,我们通过对 Long Task 的分析,可以有效的找到具体哪些任务导致了页面卡顿。
Long Task 可以通过 PerformanceObserver 获取。

1
2
3
4
5
6
7
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log("longtask candidate: ", entry.startTime);
}
});

observer.observe({ entryTypes: ["longtask"] });

DNS lookup

DNS 解析耗时,可以使用 performance 中的 domainLookupEnd - domainLookupStart 获取。

参考链接

https://web.dev/articles/vitals
https://web.dev/explore/learn-core-web-vitals
https://developer.chrome.com/docs/lighthouse/performance/
https://mp.weixin.qq.com/s/TRY2mEMl4rZz3442SzC1EA?poc_token=HGPaSGWjW1CXHu3f8iyJPZiXY5QPKzU4e7Wowbf8
https://github.com/berwin/Blog/issues/46