配了三年性能监控,今天发现浏览器自己会量「哪帧在卡」了——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:234 的 handlePayment() 函数,是同步执行了一个 filter + sort,然后第三方标签管理器又在其后追加了 60ms。
你知道了具体是哪个函数、哪行代码、以及第三方代码的占比。
对比 Long Tasks:不是升级,是换了一套度量体系
| Long Tasks | LoAF | |
|---|---|---|
| 度量单位 | 任务(task) | 帧(frame) |
| 超时阈值 | 50ms | 50ms |
| 归因粒度 | 无 | 精确到函数+URL |
| 与 INP 关联 | 间接 | 直接(firstUIEventTimestamp) |
| 渲染阶段 | 无 | 有(renderStart, styleAndLayoutStart) |
| 浏览器支持 | 宽(Chromium/Firefox) | 窄(仅 Chromium) |
LoAF 有一件事比 Long Tasks 诚实:它保证每帧最多只有一个渲染阶段。所以它敢暴露 renderStart 和 styleAndLayoutStart——这两个时间戳能让你清楚看到「这帧的时间花在了脚本执行上,还是花在了样式计算和布局上」。
反过来,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 告诉你哪一帧在卡。
评论区
登录后可评论。