别再只看 Long Task 了:页面卡顿到底是 React、Layout,还是 V8 GC?
"页面卡了,到底是谁的锅?"
🎬 在开始之前,先看看这个
在阅读任何文字之前,请先看这个视频:
🎬 点击播放视频实录
- UI彻底死亡:主线程被冻结数百毫秒
- 红线断崖:postMessage通道完全崩溃(延迟→∞)
- 绿线傲慢:AudioWorklet 物理心跳依然丝滑跳动
这不是特效,这是发生在你浏览器里的物理事实。
0. 页面卡了,老板只问一句话
用户说页面卡。产品说转化掉了。后端说接口很快。前端打开 DevTools,只看到一坨 Long Task。
于是所有人开始猜:是不是 React 组件太多?是不是列表没虚拟滚动?是不是 CSS layout thrashing?是不是 Chrome 又抽风?
传统前端监控只能告诉你"卡了",但很难告诉你"是谁让世界暂停了"。
这里顺手点名 rAF、PerformanceObserver、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,要记录上下文。
typescript
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(),
})
},
})
建议上报字段:
| 字段 | 作用 |
|---|---|
deltaMs |
STW 或调度尖峰长度 |
route |
哪个页面最容易卡 |
recentAction |
是否发生在点击、输入、滚动之后 |
recentLongTasks |
和 Long Task 做交叉验证 |
visibilityState |
排除后台标签页误判 |
deviceMemory |
低端设备分层 |
hardwareConcurrency |
CPU 核心数分层 |
browser |
Chrome / 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+ |
降级策略 :在不支持的环境中,可以回退到基于 postMessage 或 rAF 的轻量监控,虽然会被主线程卡死影响,但总比没有监控要好。
8. 升维:前端性能监控要从"指标"走向"物理观测"
过去我们用指标描述用户体验:LCP、FID、INP、CLS。现在我们还需要一层更底层的东西:物理心跳。
因为当主线程停止呼吸时,所有跑在主线程里的监控都会变成事后回忆。
STW Sentinel 不是为了证明 AudioWorklet 有多酷,而是为了把前端卡顿从玄学、猜测和甩锅,拉回到可观测、可归因、可复现的工程系统里。
如果你只想试一下,5 行代码接入:
bash
npm install stw-sentinel
如果你想定位真实业务卡顿,请记录上下文、交叉 Long Task、按路由和设备聚合。
页面卡了不可怕,可怕的是你不知道它为什么卡。
🔗 相关文章:
- 隔离地狱:我用一根红色尖峰,活捉了 V8 的幽灵 --- 发现原理
- stw-sentinel 接入指南:5 行代码给你的前端装上体外心跳 --- 接入教程
- V8 冻结 700ms,AudioWorklet 心跳 2.67ms --- 技术深潜
- 16÷4 陷阱:一个让 AudioWorklet 数据错位的字节幻觉 --- 踩坑实录
🔗 在线实验室 :diffserv.xyz/lab
🔗 GitHub :github.com/hlng2002/st...