前言
大家好,我是抹茶。在前端的工作开发过程当中,大家都想开发出用户体验良好、高性能的页面,但是我们以怎样的方式和指标去衡量性能的好坏呢?
前置理解:页面的效果是相对的
-
同样的页面,用户A和用户B感受到的页面性能可能是不一样的。(A在网速好的地方,使用的是高性能的设备,用户B在网络差的地方,用的设备比较老性能差)
-
在同样的时间内加载完成的两个网站,网站A可能在用户感官上觉得加载比B快(A进行了渐进式的加载,而网站B等所有资源都到位才一次性展示内容)
-
有的网站看起来可能加载、渲染得很快,但是却迟迟无法响应用户的交互(比如: 点击事件、键盘输入)
基于过去的实践,我们用怎样的新框架衡量指标更合适?
过去,常常用load事件来衡量,但是load时刻并不一定和用户关心的任何时刻相对应。
比如,服务器返回很小的页面,load事件很快就触发,但是这是采取请求主要的网页内容。从技术上来说,页面的加载时间短,但是用户在加载上的体验并不一定觉得高效。
页面性能需要从多个角度来衡量,单一角度的最优并不不能表示整体的最优。
Chrome团队和W3C Web性能工作组合作,致力于定义和开发一组新API和指标,对页面性能指标进行标准化,从而更准确衡量用户对页面性能的体验。
其框架设计围绕下面几个问题
| 维度 | 细节 |
|---|---|
| 是否发生? | 导航成功了吗? 服务器有响应吗? |
| 是否有用? | 有足够多用户感兴趣的内容吗? |
| 是否可用? | 用户可以与页面互动吗?还是页面一直繁忙? |
| 是否令人愉悦? | 互动是否顺畅、自然,没有延迟? |
从哪些方式进行衡量
效果指标一般通过以下两种方式之一进行衡量:
- 实验指标:用工具在一致且受控的环境中模拟页面加载
- 用户互动:真实用户实际加载页面并与之互动
一般同时使用这两种方式来确保良好的效果。
从哪些维度定义指标类型
- 感知的加载速度: 网页能够以多快的速度加载网页中的所有视觉元素并将其呈现在屏幕上
- 加载响应的速度: 网页为了能够快速响应用户互动而加载和执行任何必须的JS代码的速度
- 运行时的响应速度: 网页加载后,网页对用户互动的响应速度如何
- 视觉稳定性: 网页上的元素是否以用户意想不到的方式偏移,并且可能会干扰用户的互动?
- 流畅性: 过渡和动画是否以一致的帧速率渲染,并在一中状态之间流畅的流动?
没有任何一个单一的指标能够充分捕获网页的所有性能特征。
当前定义衡量性能的重要指标
-
First Contentful Paint (FCP):
首次内容绘制衡量从网页开始加载到网页内容的任何部分在屏幕上呈现的时间。 -
Largest Contentful Paint (LCP):
最大内容绘制衡量从网页开始加载到屏幕上呈现最大的文本块或图片元素所用的时间。 -
Interaction to Next Paint (INP):
交互至下一次绘制衡量与网页进行的每个点按、点击或键盘互动的延迟时间,并根据互动次数选择网页最差(或接近最长的互动延迟时间)作为单个代表性值,以描述网页的整体响应能力。 -
Total Blocking Time (TBT):
总阻塞时间衡量FCP和TTI之间的总时长,在该时间段内,主线程处于阻塞状态的时长。 -
Cumulative Layout Shift (CLS):
累计布局偏移衡量从网页开始加载到其生命周期状态变为隐藏期间发生的所有意外布局偏移的累计得分。 -
Time to First Byte (TTFB):
首字节时间衡量网络使用资源的第一个字节响应用户请求所需的时间。
自定义指标会用到的API
核心:Performance Observer API
Performance Observer API 是一种机制,用于收集和现实所有其他核心Performance API的数据
PerformanceObserver的回调将浏览器在空闲时段触发,通常不会干扰到页面性能。
创建PerformanceObserver时,传递一个回调函数。使用observe()方法可以告知观察器要监听哪些类型的数据。
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 打印条目和相关的细节
console.log('performanceObserver',entry.toJSON());
}
});
po.observe({type: 'some-entry-type'});
通过PerformanceObserver.supportedEntryTypes属性检查有哪些类型可用。
[
"element",
"event",
"first-input",
"largest-contentful-paint",
"layout-shift",
"long-animation-frame",
"longtask",
"mark",
"measure",
"navigation",
"paint",
"resource",
"visibility-state"
]
如果要获取历史的数据,调用observe()时将buffered标志设置为true。
po.observe({
type:'some-entry-type',
buffered:true,
})
具体的各个性能指标API(以PerformanceObserver观察对象做区分)
1. User Timing API
User Timing API是一种通用的测量api。它支持在任意地方做标记,然后测量标记之间的用时。
// Record the time immediately before running a task.
performance.mark('myTask:start');
await doMyTask();
// Record the time immediately after running a task.
performance.mark('myTask:end');
// Measure the delta between the start and end of the task
performance.measure('myTask', 'myTask:start', 'myTask:end');
如果需要报告User Timing测量结果,可以使用PerformaceObserver进行注册,并观察measure类型的条目。
// Create the performance observer.
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Log the entry and all associated details.
console.log(entry.toJSON());
}
});
// Start listening for `measure` entries to be dispatched.
po.observe({type: 'measure', buffered: true});
采用自定义mark,measure类型监测到的数据如下所示:
2. Long Animation Frames API
Long Animation Frames API处理的是超过50毫秒的长帧。
可以用PerformanceObserver注册并观察long-animation-frame类型的条目。
// Create the performance observer.
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Log the entry and all associated details.
console.log(entry.toJSON());
}
});
// Start listening for `long-animation-frame` entries to be dispatched.
po.observe({type: 'long-animation-frame', buffered: true});
Long Animation Frames可以收集到的数据如下图:
3. Element Timing API
Largest Contentful Paint(LCP)指标有助于了解最大的图片或者文本块何时绘制屏幕。LCP API实际上是基于Element Timing API来构建的,并且添加了针对最大内容元素的自动报告。
也可以通过对HTML元素明确添加elementtiming实行并注册PerformanceObserver来观察element条目类型来报告其他元素(也就是可以自己设定哪个是最大内容元素)。
<img elementtiming="hero-image" />
<p elementtiming="important-paragraph">This is text I care about.</p>
<!-- ... -->
<script>
const po = new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
// Log the entry and all associated details.
console.log(entry.toJSON());
}
});
// Start listening for `element` entries to be dispatched.
po.observe({type: 'element', buffered: true});
</script>
对应的监控数据如下:
4. Event Timing API
Interaction to Next Paint (INP) 指标通过观察网页在整个生命周期内的所有点击、点按和键盘互动情况来评估总体网页响应能力。网页的 INP 通常是指从用户发起互动到浏览器绘制下一帧以显示用户输入的视觉结果,完成时间最长的互动。
可以通过 Event Timing API 使用 INP 指标。此 API 公开了事件生命周期中发生的许多时间戳,包括:
- startTime: 浏览器收到事件的时间。
- processingStart: 浏览器能够开始处理事件的响应脚本时间。
- processingEnd: 浏览器完成从此事件的事件处理脚本发起的所有同步代码的操作的时间。
- duration: 浏览器收到事件到能够绘制下一帧所用的时间(出于安全考虑,四舍五入为 8 毫秒)。执行完从事件处理脚本发起的所有同步代码后,浏览器可绘制下一帧。
const po = new PerformanceObserver((entryList) => {
// 获取最后观察到的交互
const entries = Array.from(entryList.getEntries()).forEach((entry) => {
// 获取各种交互数据:
// 脚本开始执行前的延迟时间
const inputDelay = entry.processingStart - entry.startTime;
// 处理脚本的执行时间
const processingTime = entry.processingEnd - entry.processingStart;
const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;
const duration = entry.duration;
const eventType = entry.name;
const target = entry.target || "(not set)"
console.log("----- INTERACTION -----");
console.log(`输入延迟 (ms): ${inputDelay}`);
console.log(`事件处理的时间 (ms): ${processingTime}`);
console.log(`事件响应的整体滞后时间 (ms): ${presentationDelay}`);
console.log(`事件总的持续时间(ms): ${duration}`);
console.log(`事件类型: ${eventType}`);
console.log(target);
});
});
// A durationThreshold of 16ms is necessary to include more
// interactions, since the default is 104ms. The minimum
// durationThreshold is 16ms.
po.observe({type: 'event', buffered: true, durationThreshold: 16});
监测到的数据如下图:
5. Resource Timing API
通过Resource Timing API, 可以详细了解网页的资源是如何加载的。可以访问的数据包括
-
initiatorType: 资源的获取方式:例如从
-
nextHopProtocol: 用于提取资源的协议,例如 h2 或 quic。
-
encodedBodySize/decodedBodySize]: 以编码或解码形式表示的资源大小
-
transferSize: 通过网络实际传输的资源的大小。当缓存用尽资源时,此值可以比 encodedBodySize 小得多,在某些情况下可能为零(如果不需要重新验证缓存)。
// Create the performance observer.
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// If transferSize is 0, the resource was fulfilled using the cache.
console.log(entry.name, entry.transferSize === 0);
}
});
// Start listening for `resource` entries to be dispatched.
po.observe({type: 'resource', buffered: true});
resource可以收集的数据如下图:
6. Navigation Timing API
Navigation Timing API 与 Resource Timing API 类似,但只会报告导航请求。navigation 条目类型也与 resource 条目类型类似,但它包含一些仅适用于导航请求的其他信息(例如,何时触发 DOMContentLoaded 和 load 事件)。
许多开发者跟踪的一个指标是了解服务器响应时间(第一字节时间 (TTFB)),可通过 Navigation Timing API 获取,具体而言是条目的 responseStart 时间戳。
// Create the performance observer.
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// If transferSize is 0, the resource was fulfilled using the cache.
console.log('Time to first byte', entry.responseStart);
}
});
// Start listening for `navigation` entries to be dispatched.
po.observe({type: 'navigation', buffered: true});
使用 Service Worker 的开发者可能关心的另一个指标是导航请求的 Service Worker 启动时间。这是浏览器在开始拦截提取事件之前启动 Service Worker 线程所用的时间。
特定导航请求的 Service Worker 启动时间可以根据 entry.responseStart 和 entry.workerStart 之间的增量确定。
// Create the performance observer.
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Service Worker startup time:',
entry.responseStart - entry.workerStart);
}
});
// Start listening for `navigation` entries to be dispatched.
po.observe({type: 'navigation', buffered: true});
navigation可以观察到数据如图:
7. Server Timing API
使用Server Timing API,可以通过响应头将特定于请求的时间数据从服务器传递到浏览器。比如,指明在数据库中查找特定请求的数据所花费的时间,这对于调试由服务器速度缓慢导致的性能问题非常有用。
对于使用第三方分析服务提供商的开发者而言,Server Timing API 是将服务器性能数据与这些分析工具可能衡量的其他业务指标关联起来的唯一途径。
如需在响应中指定服务器计时数据,可以使用 Server-Timing 响应头。示例如下:
HTTP/1.1 200 OK
Server-Timing: miss, db;dur=53, app;dur=47.2
然后,可以在页面中读取 Resource Timing API 和 Navigation Timing API 中的 resource 或 navigation 条目的此类监控数据。
// Create the performance observer.
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Logs all server timing data for this response
console.log('Server Timing', entry.serverTiming);
}
});
// Start listening for `navigation` entries to be dispatched.
po.observe({type: 'navigation', buffered: true});
取消监听
使用PerformanceObserver.disconnect()可以取消监听,释放内存
参考链接
测试Demo
demo是运行在vue3项目的,源码如下:
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterView } from 'vue-router';
let po = ref<any>(null);
function getEventTiming () {
po.value = new PerformanceObserver((entryList) => {
// 获取最后观察到的交互
const entries = Array.from(entryList.getEntries()).forEach((entry) => {
// 获取各种交互数据:
// 脚本开始执行前的延迟时间
const inputDelay = entry.processingStart - entry.startTime;
// 处理脚本的执行时间
const processingTime = entry.processingEnd - entry.processingStart;
const presentationDelay = entry.startTime + entry.duration - entry.processingEnd;
const duration = entry.duration;
const eventType = entry.name;
const target = entry.target || "(not set)"
console.log(entry.toJSON());
console.log("----- INTERACTION -----");
console.log(`输入延迟 (ms): ${inputDelay}`);
console.log(`事件处理的时间 (ms): ${processingTime}`);
console.log(`事件响应的整体滞后时间 (ms): ${presentationDelay}`);
console.log(`事件总的持续时间(ms): ${duration}`);
console.log(`事件类型: ${eventType}`);
console.log(target);
});
});
// A durationThreshold of 16ms is necessary to include more
// interactions, since the default is 104ms. The minimum
// durationThreshold is 16ms.
po.value.observe({type: 'event', buffered: true, durationThreshold: 16});
}
function getAllTiming() {
po.value = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// 打印条目和相关的细节
console.log(`${entry.entryType}`, entry.toJSON());
console.log('\n');
}
});
po.value.observe({
entryTypes: [
'element',
'event',
'first-input',
'largest-contentful-paint',
'layout-shift',
'long-animation-frame',
'longtask',
'mark',
'measure',
'navigation',
'paint',
'resource',
'visibility-state',
],
buffered: true,
durationThreshold: 16
});
// po.value.observe({ type: 'event', buffered: true });
// po.value.observe({ type: 'first-input', buffered: true });
// po.value.observe({ type: 'largest-contentful-paint', buffered: true });
// po.value.observe({ type: 'layout-shift', buffered: true });
// po.value.observe({ type: 'long-animation-frame', buffered: true });
// po.value.observe({ type: 'longtask', buffered: true });
// po.value.observe({ type: 'measure', buffered: true });
// po.value.observe({ type: 'navigation', buffered: true });
// po.value.observe({ type: 'paint', buffered: true });
// po.value.observe({ type: 'resource', buffered: true });
// po.value.observe({ type: 'visibility-state', buffered: true });
}
function getUserTiming() {
// Record the time immediately before running a task.
performance.mark('myTask:start');
setTimeout(() => {
// Record the time immediately after running a task.
performance.mark('myTask:end');
// Measure the delta between the start and end of the task
performance.measure('myTask', 'myTask:start', 'myTask:end');
}, 4000);
}
onMounted(() => getAllTiming());
onMounted(() => getEventTiming());
onMounted(() => getUserTiming());
</script>
<template>
<p elementtiming="foobar" >我是文本块</p>
<p elementtiming="important-paragraph">
This is text I care about.
</p>
<RouterView />
</template>