别再只看 Long Task 了:页面卡顿到底是 React、Layout,还是 V8 GC?

8 阅读8分钟

别再只看 Long Task 了:页面卡顿到底是 React、Layout,还是 V8 GC?

"页面卡了,到底是谁的锅?"

🎬 在开始之前,先看看这个

在阅读任何文字之前,请先看这个视频:

🎬 点击播放视频实录

  • UI彻底死亡:主线程被冻结数百毫秒
  • 红线断崖:postMessage通道完全崩溃(延迟→∞)
  • 绿线傲慢:AudioWorklet 物理心跳依然丝滑跳动

这不是特效,这是发生在你浏览器里的物理事实。

0. 页面卡了,老板只问一句话

用户说页面卡。产品说转化掉了。后端说接口很快。前端打开 DevTools,只看到一坨 Long Task。

于是所有人开始猜:是不是 React 组件太多?是不是列表没虚拟滚动?是不是 CSS layout thrashing?是不是 Chrome 又抽风?

传统前端监控只能告诉你"卡了",但很难告诉你"是谁让世界暂停了"。

这里顺手点名 rAFPerformanceObserver、Long Task、web-vitals 的局限:它们都在主线程语境里观察主线程。

1. 为什么传统卡顿监控会失明?

核心论点:如果监控代码和业务代码在同一个线程,它们会一起死。

1.1 requestAnimationFrame

能看到帧间隔变大,但它自己也被主线程调度影响。它像是在心脏停跳后醒来补记一笔:"刚才好像断片了 700ms。"

另外需要注意的是,当页面处于后台标签页时,浏览器会暂停 rAF 回调以节省电量,这也会导致帧间隔看起来非常大(可能达到数秒),但这并不是被 STW 卡住,而是浏览器的正常省电行为。这也是为什么在生产监控中必须结合 document.visibilityState 来判断 rAF 间隔异常的真实原因。

1.2 Long Task API

能看到超过 50ms 的主线程长任务,但它更擅长记录 JS 执行和任务阻塞,不等于能精确切开 V8 STW 的瞬间。

1.3 DevTools Performance

适合开发环境复盘,但不适合生产环境持续采样。用户现场不会帮你开 DevTools。

这一节的结尾要引出:我们需要一个不坐在主线程里的观察者。

2. STW Sentinel 的定位:不是替代 web-vitals,而是补上黑匣子

不要把 stw-sentinel 写成"吊打所有监控"。更高级的写法是:

web-vitals 看用户体验结果,Long Task 看主线程任务,STW Sentinel 看主线程之外的物理心跳。

监控手段能回答的问题盲区
web-vitals用户体验是否变差很难解释底层原因
Long Task主线程是否被长任务占用不一定能区分业务 JS、Layout、GC
rAF delta帧是否断了采样者自己也会被卡住
STW Sentinel主线程冻结期间外部时间是否仍稳定流逝需要 COOP/COEP 与 AudioWorklet 环境

STW Sentinel 不是性能监控的全部,而是卡顿归因链路里缺失的那颗钉子。

3. 生产接入架构:不要只 console.warn,要做事件归因

不要只记录 deltaMs,要记录上下文。

import { STWSentinel } from 'stw-sentinel'

const sentinel = new STWSentinel({
  thresholdMs: 10,
  onSpike: (deltaMs, entry) => {
    // deltaMs 已经是换算好的毫秒值
    // 如果需要原始微秒值:const deltaUs = entry.deltaUs
    reportSTW({
      deltaMs,
      deltaUs: entry.deltaUs, // 原始微秒值,精度更高
      timestamp: performance.now(),
      route: location.pathname,
      visibility: document.visibilityState,
      userAgent: navigator.userAgent,
      recentAction: getLastUserAction(),
      recentLongTasks: getRecentLongTasks(),
      memory: getMemorySnapshotSafely(),
    })
  },
})

建议上报字段:

字段作用
deltaMsSTW 或调度尖峰长度
route哪个页面最容易卡
recentAction是否发生在点击、输入、滚动之后
recentLongTasks和 Long Task 做交叉验证
visibilityState排除后台标签页误判
deviceMemory低端设备分层
hardwareConcurrencyCPU 核心数分层
browserChrome / Edge / Safari 差异
releaseVersion对应前端版本回归

4. 卡顿归因矩阵:如何判断是谁的锅?

情况 A:Long Task 高,STW 不高

结论倾向:业务 JS、React render、同步计算、JSON parse、大循环、第三方 SDK。

处理方向:

  • 拆任务
  • useMemo / memo
  • 虚拟列表
  • Web Worker
  • 减少同步 JSON parse
  • 延迟第三方 SDK 初始化

情况 B:Long Task 高,STW 也高

结论倾向:业务代码制造了内存压力,触发 V8 GC/STW。

注意:V8 GC 本身不会产生独立的 Long Task 条目。GC 停顿通常表现为某个已有业务任务的执行时间被异常拉长(例如一个 30ms 的任务因为触发 GC 变成 120ms)。Long Task API 不会单独记录"GC 花了 90ms",只会记录这个被拉长的业务任务及其 attribution。

补充说明:现代 V8 的 GC 已经通过 Orinoco 项目做了大量并发优化(并发标记、并发清扫等),大多数场景下面临的是短暂的 STW 停顿。但在高内存压力、大堆、频繁分配的场景下,仍可能出现百毫秒级的 STW 停顿。

典型场景:

  • 短时间创建大量对象
  • 大数组频繁 map/filter/reduce
  • 虚拟 DOM 大规模重建
  • 不可控缓存膨胀
  • 频繁 JSON.parse/stringify
  • 大对象深拷贝

情况 C:STW 高,但 Long Task 不明显

结论倾向:传统主线程观测没抓到完整现场,或者 GC 停顿发生在监控盲区。

处理方向:

  • 看内存分配曲线
  • 看路由切换前后的对象增长
  • 看第三方脚本
  • 看是否存在大规模临时对象

情况 D:rAF 掉帧,但 STW 稳定

结论倾向:渲染、布局、合成、GPU、CSS、图片解码等问题。

处理方向:

  • 查 Layout Thrashing
  • 查 forced reflow
  • 查大面积 repaint
  • 查 CSS filter/backdrop-filter
  • 查图片解码与 canvas

情况 E:STW 高,但代码看起来没问题

结论倾向:浏览器扩展脚本干扰、或第三方脚本异常。

处理方向:

  • 在隐身窗口复现问题,排除扩展干扰
  • 检查是否有注入脚本
  • 使用 Chrome DevTools 的 Performance 面板录制,查看 Call Tree 里是否有陌生脚本

5. 一个真实案例:React 页面卡顿,最后不是 React 的锅

案例结构:

  • 页面:大型数据看板
  • 现象:切换筛选条件时偶发 300ms 卡顿
  • 传统监控:Long Task 记录不稳定
  • 怀疑对象:React 组件重渲染
  • 接入 STW Sentinel:发现卡顿前后出现 120ms STW spike
  • 继续排查:筛选逻辑中大量 JSON 深拷贝 + 临时对象创建
  • 修复:结构共享、缓存复用、减少中间数组
  • 结果:STW spike 从 120ms 降到 18ms,交互延迟下降

我们不是让 V8 不 GC,而是减少把 V8 逼到 Stop-The-World 的概率。

6. 阈值怎么设:不要迷信 16.6ms

  • 5ms 以下:通常不需要报警,但可以采样
  • 10ms:适合开发环境敏感阈值
  • 16.6ms:一帧预算
  • 50ms:Long Task 标准线
  • 100ms+:用户明显感知
  • 300ms+:交互断裂
  • 700ms+:事故现场

推荐策略:

  • 开发环境:thresholdMs = 5~10
  • 灰度环境:thresholdMs = 10~20
  • 生产环境:分层采样,重点记录 50ms+ 和 100ms+

阈值不是物理真理,是业务容忍度。 游戏、音频、交易、编辑器、看板、后台管理系统的阈值不一样。

7. 生产环境注意事项:这把武器有保险

7.1 COOP/COEP 会影响资源加载

很多人配置 Cross-Origin-Embedder-Policy: require-corp 后,会发现第三方图片、脚本、iframe、CDN 资源出问题。

建议:

  • 先在实验域名或灰度域名启用
  • 检查第三方资源 CORP/CORS
  • 避免直接在全站裸上

7.2 AudioContext 必须用户手势后启动

建议:

  • 在用户第一次点击、滚动、输入后懒启动
  • 不要在页面加载时强行初始化
  • 对后台标签页降采样或暂停

7.3 不要全量上报所有心跳

生产环境只上报异常尖峰和少量采样窗口。

  • 正常心跳留在本地环形缓冲区
  • 超过阈值才 drain + report
  • 同一 session 做限流

7.4 兼容性要诚实

不是所有浏览器、所有嵌入环境都适合跑这套东西。尤其是微信内置浏览器、企业内嵌 WebView、老 Safari、跨域资源复杂的老项目,都要给降级策略。

环境支持情况备注
Chrome 66+✅ 完整支持AudioWorklet + SAB 完整支持
Edge 79+✅ 完整支持基于 Chromium
Safari 14.5+⚠️ 部分支持AudioWorklet 支持,但 SAB 限制更严格
Safari 14.4 及以下❌ 不支持AudioWorklet 未实现
Firefox 76+⚠️ 部分支持AudioWorklet 支持,但 COOP/COEP 行为有差异
微信内置浏览器❌ 通常不支持取决于底层内核版本
企业 WebView (Android)⚠️ 取决于系统 WebView 版本需要 Android 7+

降级策略:在不支持的环境中,可以回退到基于 postMessagerAF 的轻量监控,虽然会被主线程卡死影响,但总比没有监控要好。

8. 升维:前端性能监控要从"指标"走向"物理观测"

过去我们用指标描述用户体验:LCP、FID、INP、CLS。现在我们还需要一层更底层的东西:物理心跳。

因为当主线程停止呼吸时,所有跑在主线程里的监控都会变成事后回忆。

STW Sentinel 不是为了证明 AudioWorklet 有多酷,而是为了把前端卡顿从玄学、猜测和甩锅,拉回到可观测、可归因、可复现的工程系统里。


如果你只想试一下,5 行代码接入:

npm install stw-sentinel

如果你想定位真实业务卡顿,请记录上下文、交叉 Long Task、按路由和设备聚合。

页面卡了不可怕,可怕的是你不知道它为什么卡。


🔗 相关文章:

🔗 在线实验室diffserv.xyz/lab

🔗 GitHubgithub.com/hlng2002/st…