如何用 Long Animation Frames API 定位卡顿来源?
解释长动画帧与 Long Task 的区别,使用 PerformanceObserver 读取脚本、布局和渲染耗时,分析如何在真实用户环境中定位交互卡顿并设计降级方案。
简答
- LoAF 以帧为单位观察超过 50ms 的渲染更新,能发现多个中等任务合起来造成的卡顿。
- 用 PerformanceObserver 观察 long-animation-frame,buffered 只能读到缓冲区内的条目。
- scripts 字段给出调用者、脚本 URL 和强制布局等归因信息。
- 按分位数聚合后再优化,并与 Long Tasks、Event Timing 配合;需要特性检测。
页面明明没有一个持续几百毫秒的函数,用户却感觉点击后画面停顿。原因可能是多个中等耗时的脚本、样式计算、布局和绘制共同超过了一帧预算。只观察 Long Task 只能看到主线程上的长任务,无法完整回答“这一帧为什么没有及时呈现”。Long Animation Frames(LoAF)记录的是一帧从开始到完成的整体耗时,并提供参与这帧的脚本和渲染阶段信息。
长动画帧和长任务观察的对象不同
- Long Task:单个主线程任务持续至少 50 毫秒,适合发现某段 JavaScript 或其他任务长时间占用主线程。
- Long Animation Frame:观察超过 50 毫秒的长帧处理,包含跨任务的脚本工作和渲染更新信息。50 毫秒是长帧报告阈值,不是流畅动画的帧预算;60 Hz 屏幕的一次刷新间隔约为 16.7 毫秒。
两者可以同时出现,也可能只有其中一个被观察到。例如若多个 30 毫秒的脚本任务被安排在同一帧,它们单独都不到 50 毫秒,却可能共同造成长帧。反过来,一个长任务也可能发生在没有形成完整帧的时段。诊断时不要把两套指标当成重复数据相加。
先用 PerformanceObserver 收集长帧
function observeLongAnimationFrames(onEntry) {
if (!('PerformanceObserver' in window)) return () => {};
const supported = PerformanceObserver.supportedEntryTypes ?? [];
if (!supported.includes('long-animation-frame')) return () => {};
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
onEntry({
startTime: entry.startTime,
duration: entry.duration,
blockingDuration: entry.blockingDuration,
renderStart: entry.renderStart,
styleAndLayoutStart: entry.styleAndLayoutStart,
scripts: entry.scripts.map((script) => ({
invoker: script.invoker,
sourceURL: script.sourceURL,
sourceFunctionName: script.sourceFunctionName,
duration: script.duration,
pauseDuration: script.pauseDuration,
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration,
})),
});
}
});
observer.observe({ type: 'long-animation-frame', buffered: true });
return () => observer.disconnect();
}
const stop = observeLongAnimationFrames((frame) => {
if (frame.duration >= 200) {
console.log('发现严重长帧', frame);
}
});
buffered: true 允许观察器读取观察开始前已经记录的条目,但只能读取浏览器保留的缓冲区,不等于能拿到页面生命周期内的全部历史。上报时应采样、限制字段长度并去掉用户输入等敏感信息;不能把每一帧的完整脚本路径无过滤地发送到远端。
blockingDuration 描述主线程无法响应用户输入等高优先级任务的估算阻塞时间,不是 GPU 绘制时长,也不是 duration 减去一个刷新间隔。MDN 的计算说明围绕帧内超过 50 毫秒的任务,结合最长任务的渲染耗时计算;因此不能把它与 INP 或帧总时长直接互换。
renderStart 包含 requestAnimationFrame 回调、样式布局和观察器回调等渲染周期工作,styleAndLayoutStart 标记样式布局阶段开始;不能把两者之差命名为“GPU 耗时”。这些时间戳可能为 0,应先检查再计算。较新的接口文档还描述了 paintTime、presentationTime,但目标浏览器未必支持;本例没有读取它们,不能声称已测得实际屏幕呈现等待。需要时另行做字段检测并与 DevTools 时间线核对。
scripts 能帮助定位“谁触发了这一帧”
每个脚本记录通常会包含调用者、脚本 URL、函数名、脚本自身耗时,以及是否触发了强制样式或布局。可以用它区分几类常见问题:
- 点击处理函数同步计算大量数据;
- 第三方脚本在交互时执行同步工作;
- 先写 DOM 再立即读布局属性,触发强制同步布局;
- MutationObserver、ResizeObserver 或框架提交在同一帧集中修改节点;
- 样式选择器或 DOM 规模导致样式计算和布局耗时上升。
记录中的 sourceURL 可能为空、被打包成产物地址,或因为跨域与隐私策略无法提供完整源码位置。生产环境应配合 Source Map 和版本标识在服务端还原,但不要把 Source Map 内容直接暴露给所有用户。
不要只根据一个长帧就改代码
真实用户数据可能受到后台标签页、设备降频、扩展程序和操作系统调度影响。应按页面、设备等级、交互类型和版本聚合,关注 P75/P95 等分位数,并结合用户动作和业务结果。一次 500 毫秒长帧说明需要调查,但不能单独证明某个函数是唯一原因。
开发环境可以用 Performance panel、CPU throttling 和录制的调用栈复现;生产环境则只上报经过采样的摘要,例如页面路由、交互名称、帧耗时区间、脚本来源类别和是否发生强制布局。用户输入、完整 URL 查询参数和内部路径应在采集前脱敏。
和 Long Task、Event Timing 一起看
LoAF 适合解释帧级阻塞,Long Task 适合发现单个长任务,Event Timing 适合观察输入事件从发生到处理和呈现的延迟。三者应使用共同的页面版本和时间线关联,而不是把每个指标的数值直接相加。比如一个输入延迟很高的事件可能由多个短任务组成,此时 LoAF 能补充“同一帧内还有哪些阶段耗时”。
兼容性和降级
Long Animation Frames API 仍不是所有浏览器和嵌入式 WebView 都支持。特性检测失败时,页面功能不能因此中断,可以降级为:
- 使用
PerformanceObserver观察longtask; - 通过 Event Timing 或自定义交互标记记录关键路径;
- 在开发环境用 DevTools Performance panel 做详细分析;
- 对不支持的环境只保留基础页面和请求耗时指标。
不要为了采集性能数据在每个 requestAnimationFrame 中执行复杂逻辑,这会改变被测页面本身的性能。观察器回调也应快速结束,必要时把摘要放入队列,使用 navigator.sendBeacon() 或合适的后台发送方式批量上报。
优化方向取决于证据
如果脚本耗时占主导,可以拆分任务、延迟非关键工作、使用 Worker 或减少渲染次数;如果强制布局明显,应把 DOM 写入和读取分组,并减少布局依赖;如果样式和布局占主导,应缩小 DOM、简化选择器或使用合适的 content-visibility 等策略。优化后要在同一设备和交互场景重新测量,确认长帧分位数和用户实际输入延迟都改善,而不是只看到某一条调用栈变短。