web-vitals 源码阅读

1,216 阅读5分钟

web-vitals 源码阅读

出处和标准

源码 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 PerformanceEntry to 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 能监听更多类型

ts 知识

  • Utility Types:omitreturnType
  • @ts-ignore 注释绕开 ts 检查
  • ! 符号断言对象变量不为空
  • 声明全局变量