web-vitals 源码阅读
出处和标准
- github 源码:github.com/GoogleChrom…
- 官方标准:docs.sentry.io/product/per…
- npm 文档及用法:www.npmjs.com/package/web…
源码 src 目录
-
src
-
lib
-
polyfills
-
firstInputPolyfill.ts (处理点击和输入的情况,点击后滚动或双指捏合缩放的情况被排除。能用 performance.now() 就不会用 Date。从其中的判断条件可以看出:浏览器api也是有小概率出现完全错误的值的)
// firstInputPolyfill.ts 源码截取 if (firstInputDelay >= 0 && firstInputDelay < firstInputTimeStamp - startTimeStamp) { // ...trigger callback } -
getFirstHiddenTimePolyfill.ts (firstHiddenTime 的 polyfill,就是监听 visibilitychange 的 hidden,获取时间戳,然后移除事件)
-
-
bindReporter.ts (返回一个维护 metric 的 value 和 delta 的,根据条件触发回调的函数)
-
generateUniqueID.ts (基于 Date.now 和 Math.random 生成唯一键,用于 initMetric)
-
getVisibilityWatcher.ts
(每次页面隐藏,都更新时间戳,并赋值给 firstHiddenTime,返回一个含只读属性 firstHiddenTime 的对象)
-
initMetric.ts (生成符合 Metric 接口定义的实例,每个 web-vital 测量返回值都遵循这个格式)
-
observe.ts (基于 PerformanceObserver 来监听对应类型的 web-vitals,没有 PerformanceObserver.supportedEntryTypes api 的无法监听,chrome 73 以上才支持这个 api)
// observe.ts 源码截取 if (PerformanceObserver.supportedEntryTypes.includes(type)) { // ...进行监听的代码 } -
onBFCacheRestore.ts (如果是从bfcache中读取的<这里就是 pageshow 事件>,就触发传入的回调函数,bfcache 是能优化的,bfcache 可译为“往返缓存”)
-
onHidden.ts
(页面隐藏时触发,useCapture为 true,优先走 visibilitychange 事件,用 pagehide 事件作为 fallback,通过标记位决定注册后是否只触发一次。getVisibilityWatcher 每次 onBFCacheRestore 都会注册 once 为 true 的 onHidden,用于更新时间戳。)
-
-
getCLS.ts
-
getFCP.ts
-
getFID.ts (从FID的polyfill的条件判断可以看出
-
getLCP.ts
(以上四个都是通过 PerformanceObserver 来监听对应的类型,依次是 first-input,paint,layout-shift,largest-contentful-paint)
-
getTTFB.ts (通过两种方式提供 PerformanceNavigationTiming.responseStart,有更现代的的 api 就用更现代的,没有就用 fallback 的,两个都没有也会静默失败,因为包裹了 try...catch)
-
index.ts(集中 export 五个方法)
-
polyfill.ts(使用相对旧的 api 做相同的事情,使兼容性更好,而且更准一点,简单说就是监听 visibility 事件相对早一点点。详见文档的
Which bundle is right for you?) -
types.ts(类型定义文件,其中对浏览器事件对象的类型进行了二次加工)
-
触发时机,值(value),偏移量(delta)
TTFB
-
触发时机:页面 load 事件触发时
-
触发次数:只1次
-
值:浏览器全局对象 performance 的 responseStart (如果可以,通过 performance.getEntriesByType('navigation')[0] 获取。如果不行,通过 performance.timing 来获取)
-
偏移量:与值相同,代码里面直接给两个赋了相同的值:
// getTTFB.ts 源码截取 metric.value = metric.delta = (navigationEntry as PerformanceNavigationTiming).responseStart;
FID
-
触发时机:第一次输入(手指或鼠标点击,键入)
-
触发次数:多次。两次visibilitychange触发之间,只1次
-
值:输入事件触发的时间戳与js下一段逻辑代码执行所在的时间点之间的差值
-
偏移量:与值相同,代码里记录一次值就移除了相关的事件监听,而 bindReporter 的逻辑就是没有 preValue 就把 delta 给一个与 value 相同的值:
// firstInputPolyfill.ts 源码截取 /** * Records the first input delay and event, so subsequent events can be * ignored. All added event listeners are then removed. */ const recordFirstInputDelay = (delay: number, event: Event) => { if (!firstInputEvent) { firstInputEvent = event; firstInputDelay = delay; firstInputTimeStamp = new Date; eachEventType(removeEventListener); reportFirstInputDelayIfRecordedAndValid(); } }
FCP
- 触发时机:每次进入页面
- 触发次数:多次。两次visibilitychange触发之间,只1次
- 值:首次是piant完成所需的毫秒数,非首次是两帧图像渲染的时间戳的差值
- 偏移量:与值相同。两种触发场景下,两次 visibilitychange 触发之间都只触发1次(po 和 requestAnimationFrame)
CLS
- 触发时机:每次页面隐藏(导航到其他网页或者切应用)
- 触发次数:多次。两次visibilitychange触发之间,只1次
- 值:五秒内 layout-shift 触发的 entry 的值的和,没触发 entry 就是 0
- 偏移量:当次与上一次的差值
LCP
- 触发时机:首次是发生键入或点击,非首次进入页面就触发
- 触发次数:多次。两次visibilitychange触发之间,只1次(详见 Q&A)
- 值:首次是largest-contentful-paint完成所需毫秒数,非首次是两帧图像渲染的时间戳的差值(与FCP的非首次一样)
- 偏移量:与值相同。两种触发场景下,两次 visibilitychange 触发之间都只触发1次(po 和 requestAnimationFrame)
其他
Q&A
什么是首次,什么是非首次
- 首次:没有往返缓存(bfcache)
- 非首次:有往返缓存
- 即使跳到不同的域名,往返缓存仍然生效
Android
为什么首次 LCP 触发需要发生键入或点击才有返回值
首次
代码是这么写的,并在注释中解释了这样做的原因:
// getLCP.ts 代码截取
const stopListening = () => {
if (!reportedMetricIDs[metric.id]) {
po.takeRecords().map(entryHandler as PerformanceEntryHandler);
po.disconnect(); // 停止监听
reportedMetricIDs[metric.id] = true;
report(true); // 上报
}
}
// Stop listening after input. Note: while scrolling is an input that
// stop LCP observation, it's unreliable since it can be programmatically
// generated. See: https://github.com/GoogleChrome/web-vitals/issues/75
['keydown', 'click'].forEach((type) => {
addEventListener(type, stopListening, {once: true, capture: true});
});
输入后停止监听。 注意:虽然滚动是一种输入停止 LCP 观察,但不可靠,因为它可以通过编程生成。
(这是原文直译,但我有点困惑:点击等事件也可以通过代码模拟。不过作者说这不影响准确性)
首次监听的事件会往 entries 数组中推入 entry,应只取数组中最后一个元素,对应到原文就是:
For analysis purposes, you should only report the most recently dispatched
PerformanceEntryto your analytics service.
// getLCP 代码截取
if (value < visibilityWatcher.firstHiddenTime) {
metric.value = value; // 这里每次往 metric.entries 里推 entry 前都会维护 metric 的 value
metric.entries.push(entry);
report();
}
非首次
首次进入页面后,发生跳转并跳回时触发的onBFCacheRestore中的LCP上报不需要交互,而是用浏览器全局方法 requestAnimationFrame 来触发:
// getLCP.ts 代码截取
onBFCacheRestore((event) => {
metric = initMetric('LCP');
report = bindReporter(onReport, metric, reportAllChanges);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
metric.value = performance.now() - event.timeStamp;
reportedMetricIDs[metric.id] = true;
report(true);
});
});
});
由于有往返缓存,所以一般 LCP 是首次最大。但由于对用户而言每次页面加载都是用户体验的一部分,因此这一部分也应该被计算,因此也有 LCP 触发,详见文档的Differences between the metric and the API。
iOS
为什么只能检测到 TTFB 和 FCP
-
在 safari 运行以下代码,只能得到 paint,即 FCP 所使用的 PerformanceObserver 监听类型。
['first-input','paint','layout-shift','largest-contentful-paint'].filter(type => PerformanceObserver.supportedEntryTypes.includes(type)) // ["paint"] -
加上不依赖于 PerformanceObserver 的 TTFB,即总共只能监测到这两个类型。
onHidden 触发
-
safari 的控制台输出逻辑和 chrome 有不同,但实际上 safari 支持 pagehide 事件,可通过改源码调试发现:
// onHidden.ts 源码截取 export interface OnHiddenCallback { (event: Event): void; } let i = 0 export const onHidden = (cb: OnHiddenCallback, once?: boolean) => { const onHiddenOrPageHide = (event: Event) => { i++ console.log('onHiddenOrPageHide', i) if (event.type === 'pagehide' || document.visibilityState === 'hidden') { cb(event); if (once) { removeEventListener('visibilitychange', onHiddenOrPageHide, true); removeEventListener('pagehide', onHiddenOrPageHide, true); } } } addEventListener('visibilitychange', onHiddenOrPageHide, true); addEventListener('pagehide', onHiddenOrPageHide, true); };
iOS 能监听更多类型
- 通过 polyfill,safari 还能监听 FID
- CLS 和 LCP 只有 chromium 内核的浏览器才支持
- 这在文档的Browser Support 小节中也有体现
ts 知识
- Utility Types:
omit,returnType @ts-ignore注释绕开 ts 检查!符号断言对象变量不为空- 声明全局变量