长任务监控与优化深度解析:别让你的 JS 把页面「冻住」
长任务是主线程连续执行超过 50ms 的任务块,直接导致卡顿与 INP 恶化,通过 PerformanceObserver 捕获、时间切片拆分、Web Worker 卸载计算来治理。 面试常考 Long Tasks API 的捕获、时间切片与 Web Worker 的边界,以及如何把页面冻住从玄学变成可控工程。
一句话概括
长任务是主线程上连续执行超过 50ms 的任务块,它直接导致用户感知的卡顿和 INP 指标恶化。通过 PerformanceObserver 捕获、时间切片拆分、Web Worker 卸载计算,可以把「页面冻住」从玄学问题变成可控的工程实践。
核心知识点
1. 长任务的定义与捕获
浏览器每 16.67ms 渲染一帧(60fps)。如果一个任务连续占用主线程超过 50ms,意味着至少 3 帧被跳过——用户点击按钮没反应、滚动卡住、动画掉帧。Long Tasks API 可以精确捕获这些「罪魁祸首」:
1
2
3
4
5
6
7
8
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`长任务: ${entry.duration.toFixed(1)}ms, 归因: ${entry.attribution[0]?.name}`)
}
}
})
observer.observe({ type: 'longtask' }) // ⚠️ buffered 不可用,必须在页面早期注册
关键限制:attribution 只能定位到 iframe 粒度,拿不到具体函数堆栈。定位具体代码位置需配合定时堆栈采样。
2. 长任务的三大根因
大计算量 JavaScript — 一次性处理大量数据或 DOM:
1
2
3
4
5
6
7
8
// ❌ 30000 次 appendChild,每次都触发 reflow
for (let i = 0; i < 30000; i++) {
container.appendChild(document.createElement('div'))
}
// ✅ DocumentFragment 批量插入,单次 reflow
const frag = document.createDocumentFragment()
for (let i = 0; i < 30000; i++) frag.appendChild(document.createElement('div'))
container.appendChild(frag)
强制同步布局(Layout Thrashing) — 读写交替触发:
1
2
3
4
5
6
7
// ❌ 每次读取 offsetWidth 都强制浏览器先完成上一个样式的布局
els.forEach(el => {
el.style.width = `${el.offsetWidth + 10}px`
})
// ✅ 批量读,再批量写
const widths = els.map(el => el.offsetWidth)
els.forEach((el, i) => { el.style.width = `${widths[i] + 10}px` })
JSON 解析 — 移动端解析 2MB JSON 可达 100-200ms,可直接用 JSON.parse() 造成长任务。
3. 时间切片:三种让出方案
把一个超长任务拆成多个小于 50ms 的小任务,每执行完一个就把控制权还给浏览器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 方案 A:setTimeout(0) — 最通用,让出到下一个宏任务
function sliceWithTimeout(data, fn, chunkSize = 500) {
let i = 0
return new Promise(resolve => {
function next() {
const start = performance.now()
while (performance.now() - start < 10 && i < data.length) fn(data[i], i++)
i < data.length ? setTimeout(next, 0) : resolve()
}
setTimeout(next, 0)
})
}
// 方案 B:requestAnimationFrame — 适合 DOM 更新,下帧前执行
function sliceWithRAF(data, fn, chunkSize = 500) {
let i = 0
return new Promise(resolve => {
function next() {
const end = Math.min(i + chunkSize, data.length)
for (; i < end; i++) fn(data[i], i)
i < data.length ? requestAnimationFrame(next) : resolve()
}
requestAnimationFrame(next)
})
}
// 方案 C:requestIdleCallback — 适合低优先级工作,利用帧末空闲
function sliceWithIdle(data, fn) {
let i = 0
function work(deadline) {
while (deadline.timeRemaining() > 0 && i < data.length) fn(data[i], i++)
if (i < data.length) requestIdleCallback(work, { timeout: 2000 })
}
requestIdleCallback(work)
}
选型原则:DOM 操作用 rAF,日志/数据分析用 rIC,通用切片用 setTimeout。
4. Web Worker 卸载计算
对于真正重的纯计算(排序、加密、图像处理),切片不如直接扔到 Worker:
1
2
3
4
5
6
7
8
9
10
11
12
// main.js
const worker = new Worker('worker.js')
worker.postMessage({ type: 'parse', payload: largeJson })
worker.onmessage = ({ data }) => render(data.result)
// worker.js
self.onmessage = ({ data }) => {
if (data.type === 'parse') {
const result = JSON.parse(data.payload) // 在 Worker 中执行,不阻塞主线程
self.postMessage({ result })
}
}
注意:Worker 无法访问 DOM,传数据走结构化克隆(有拷贝开销),大对象建议用 Transferable。
5. scheduler.yield() — 下一代方案
Chrome 115+ 已支持原生 scheduler.yield(),无需 hack:
1
2
3
4
5
6
async function processLargeArray(items) {
for (let i = 0; i < items.length; i++) {
await processItem(items[i])
if (i % 100 === 0) await scheduler.yield() // 每 100 个让一次
}
}
比 setTimeout(0) 的优势:yield 会被调度器插入到比普通宏任务更优的位置,浏览器可以更早拿到控制权。
其实你每天都在用
- 列表无限滚动 — 手指快速滑动列表时,每次 scroll 事件回调都在抢 16ms 帧预算。不做时间切片的列表渲染,滑动到 200 条就卡成 PPT
- 批量导入 Excel — 前端解析上万行 xlsx 时,
XLSX.utils.sheet_to_json()调用可能直接长任务 300ms+ - 大盘数据看板 — 仪表盘同时渲染 20+ 个图表(ECharts init + setOption),每个都有数据转换和 Canvas 绘制
- 聊天消息渲染 — 进群后拉取 500 条历史消息,直接 innerHTML 渲染所有消息气泡,主线程冻结
- 搜狗/百度输入法词库加载 — 后台异步加载 10MB 词库 JSON → 一次性 JSON.parse → 长任务 200ms → 输入停顿。优化后用 Worker 解析 + 分批加载
常见误解(FAQ)
❌ 误区:「PerformanceObserver 能抓到长任务的代码堆栈」
实际上 PerformanceEntry.attribution 只能告诉你任务来自哪个 iframe/脚本域,不包含堆栈信息。想定位具体代码位置,必须在长任务发生时做定时堆栈采样(setInterval 每隔 5ms 取一次 new Error().stack)。
❌ 误区:「requestIdleCallback 总能及时执行」
rIC 在页面不可见时会暂停,在后台标签页中根本不执行;即使是前台,如果一帧内渲染工作多(耗时接近 16ms),rIC 回调的 deadline 剩余时间就是 0,任务被一直搁置。所以 rIC 的 { timeout: 2000 } 参数很重要——2 秒后即使页面忙也会强制执行。
❌ 误区:「时间切片 = 用 Promise + then 链」
这是经典错误。Promise.then 的回调是微任务,在同一个宏任务中被连续执行,不会让出主线程:
1
2
3
4
// ❌ 无效:微任务链仍然在同一个宏任务中
Promise.resolve().then(step1).then(step2).then(step3)
// ✅ 有效:每个 setTimeout 都是独立宏任务
setTimeout(step1, 0); setTimeout(step2, 0); setTimeout(step3, 0)
❌ 误区:「INP 只看点击延迟」
INP(Interaction to Next Paint)覆盖了 click、keydown、pointerdown 等所有离散交互。而且它取的是 P75 百分位值(即用户所有交互的延迟排名,取第 75% 的那个),不是平均值——少数几次严重卡顿就会拉高 INP。
一句话总结
长任务优化的本质不是「让代码变快」,而是「让浏览器有机会呼吸」——把一个大阻塞拆成多个可控的小停顿,主线程在每一帧都有时间处理渲染和交互,用户才不会觉得页面「死了」。