配了三年性能监控,今天发现浏览器自己会量「哪帧在卡」了——LoAF API 把这件事彻底变了

页面卡了你盯了三年 Lighthouse,今天发现根子不在代码,在浏览器自己会量了——LoAF API 把性能监控的黑箱彻底拆开了


做前端的都知道那个场景:用户说「页面卡」,你打开 Lighthouse 跑一遍,分数绿得发亮。INP 达标,LCP 达标,但用户那边就是感觉慢。问题在哪?你拿的是实验室数据,不是真实用户体验。

Chrome 123 开始引入的 LoAF API(Long Animation Frames API),把这件事彻底变了——它让浏览器自己来量「哪一帧在卡、为什么卡」。

Long Tasks 告诉你「主线程堵了」,LoAF 告诉你「谁在堵」

Long Tasks API 是上一个时代的方案。它干一件事:主线程连续忙 50ms 以上,就给你发一条通知。

const po = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("主线程堵了", entry.duration, "ms");
  }
});
po.observe({ type: "longtask" });

你知道主线程在堵,但不知道在堵什么。是某个点击事件处理函数?是第三方脚本?是样式计算?是强制重排?

LoAF API 换了度量单位——它不再量「任务」,而是量「帧」。帧超过 50ms,就给你完整归因:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log("长帧:", entry.duration, "ms",
      "阻塞:", entry.blockingDuration, "ms");

    // 这一帧里每个脚本的详细情况
    entry.scripts?.forEach((script) => {
      console.log("  脚本:", script.invoker,
        "耗时:", script.duration, "ms",
        "类型:", script.invokerType);
    });
  }
});
observer.observe({ type: "long-animation-frame", buffered: true });

输出大概长这样:

长帧: 187ms 阻塞: 137ms
  脚本: DOMWindow.onclick 耗时: 89ms 类型: event-listener
  脚本: https://cdn.third-party.com/tag.js 耗时: 41ms 类型: script

你第一次能直接看到「是这个第三方标签管理器在 click handler 里同步执行了 89ms」。

三个核心字段,把归因彻底收口

LoAF 每条记录里有几个关键字段,组合起来够你定位大多数问题:

blockingDuration — 主线程被高优先级任务(主要是用户输入)阻塞的总时间。Chrome 的计算方式:把所有超过 50ms 的长任务,减去 50ms,然后把渲染时间加进去。这个数字直接和 INP 相关。

firstUIEventTimestamp — 这一帧期间处理的第一个 UI 事件(点击、按键等)的时间戳。如果这个值存在,说明这帧和用户输入直接相关,是真正的「卡」。

scripts 数组 — 这是 LoAF 比起 Long Tasks 最大的升级。每个导致长帧的脚本都有详细归因:调用者(invoker)、来源 URL(sourceURL)、函数名(sourceFunctionName)、是事件监听器还是 promise 回调还是动画帧回调。

举一个真实场景:电商结算页,用户点支付按钮,卡了 200ms。

Long Tasks 告诉你:主线程堵了 200ms。

LoAF 告诉你:DOMWindow.onclick 耗时 120ms,来源是 checkout.js:234handlePayment() 函数,是同步执行了一个 filter + sort,然后第三方标签管理器又在其后追加了 60ms。

你知道了具体是哪个函数、哪行代码、以及第三方代码的占比。

对比 Long Tasks:不是升级,是换了一套度量体系

Long Tasks LoAF
度量单位 任务(task) 帧(frame)
超时阈值 50ms 50ms
归因粒度 精确到函数+URL
与 INP 关联 间接 直接(firstUIEventTimestamp)
渲染阶段 有(renderStart, styleAndLayoutStart)
浏览器支持 宽(Chromium/Firefox) 窄(仅 Chromium)

LoAF 有一件事比 Long Tasks 诚实:它保证每帧最多只有一个渲染阶段。所以它敢暴露 renderStartstyleAndLayoutStart——这两个时间戳能让你清楚看到「这帧的时间花在了脚本执行上,还是花在了样式计算和布局上」。

反过来,Long Tasks 的「任务」概念是浏览器实现细节,和用户感知没有直接对应关系。一帧里可以跑多个任务,但用户只看到帧的输出。

实战调试 loop:三步找到根因

Chrome 官方的推荐流程很实用:

第一步:PerformanceObserver 埋点,找到可重现的慢交互。

不要盲目优化,先确认哪个用户操作是问题。用 LoAF 观察所有超过 200ms 的帧,并记录对应的交互目标:

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.firstUIEventTimestamp && entry.duration > 200) {
      const target = entry.scripts?.[0]?.sourceFunctionName || "unknown";
      console.log(`交互卡顿: ${target} 耗时${entry.duration}ms`);
    }
  }
});
observer.observe({ type: "long-animation-frame", buffered: true });

第二步:对齐 DevTools Trace,确认机制。

LoAF 是信号,不是根因。找到可重现的慢操作后,用 Chrome DevTools Performance 面板录同一次操作。LoAF 的时间戳可以直接对应到 Trace 里的那一帧。

在 Trace 里找:同步 JS 能拆就拆;读-写交替触发的强制重排,Worker 帮不上忙;DOM 更新太大就批量或缩小范围。

第三步:控制变量,验证修复。

修完之后,再跑一遍 LoAF 观察,对比前后 blockingDuration 的变化。

整个 loop 看起来像调试,而不是扫仪表盘。

生产环境采集的两个思路

方案一:RUM 采样上报。 只上报超过阈值的帧,聚合到服务端做趋势分析。好处是数据量可控,坏处是你不知道具体是哪个函数。

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.blockingDuration > 100) {
      // 只发最差的那个脚本,不发整条记录
      const worst = entry.scripts?.sort((a, b) => b.duration - a.duration)[0];
      navigator.sendBeacon("/rum", {
        duration: entry.duration,
        blocking: entry.blockingDuration,
        script: worst?.sourceURL?.split("/").pop(),
        func: worst?.sourceFunctionName
      });
    }
  }
});
observer.observe({ type: "long-animation-frame", buffered: true });

方案二:Debug 模式本地看。 在本地或内部工具里开完整观测,生产环境不采集原始脚本信息。

关于隐私:LoAF 的脚本归因会暴露来源 URL 和函数名,属于 debug 数据。建议生产环境只聚合 duration + blockingDuration,不上报 sourceURL 或函数名,尤其当页面有第三方脚本时。

谁能用:目前只有 Chromium

LoAF 目前只有 Chrome 123+ 支持。Firefox 没有,Safari 没有。但这恰好不是问题——Chrome 是 Core Web Vitals 的数据源,你测量的用户体验本来就是 Chrome 用户的体验。覆盖范围和指标来源是一致的。

非 Chromium 浏览器目前只能 fallback 到 Long Tasks,或者用 performance.now() 自己计算帧时间,但精度和归因都差很多。

下一步:从 LoAF 信号到具体优化

LoAF 告诉你在哪卡,下一步是怎么办。常见的长帧原因和对应方案:

同步 JS 执行过长 — 把计算移出主线程,或者用 scheduler.yield() 主动让路。

强制同步布局(forced style and layout) — 读 DOM 属性(offsetWidth、getBoundingClientRect 等)紧跟在写 DOM 之后,浏览器被迫立即重新计算布局。批量读写,或者用 requestAnimationFrame 合并更新。

第三方脚本 — 识别出是哪个第三方标签在注入同步代码,考虑延迟加载或异步化。

DOM 太重 — 减少不必要的 DOM 层级,CSS 动画优先用 transform 和 opacity,这两类属性不触发重排重绘,走合成器线程。

LoAF 不是一个新仪表盘,它是一把刀——让你把「用户感觉卡」这件事,从猜测变成定位,从定位变成可量化的修复动作。

下一步:打开 DevTools,给你的页面跑一次常见用户操作(点击、滚动、提交表单),让 LoAF 告诉你哪一帧在卡。

评论区

0 条评论

登录后可评论。

阿速·性能优化 234 阅读