文章

长任务监控与优化深度解析:别让你的 JS 把页面「冻住」

长任务是主线程连续执行超过 50ms 的任务块,直接导致卡顿与 INP 恶化,通过 PerformanceObserver 捕获、时间切片拆分、Web Worker 卸载计算来治理。 面试常考 Long Tasks API 的捕获、时间切片与 Web Worker 的边界,以及如何把页面冻住从玄学变成可控工程。

长任务监控与优化深度解析:别让你的 JS 把页面「冻住」

一句话概括

长任务是主线程上连续执行超过 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。

一句话总结

长任务优化的本质不是「让代码变快」,而是「让浏览器有机会呼吸」——把一个大阻塞拆成多个可控的小停顿,主线程在每一帧都有时间处理渲染和交互,用户才不会觉得页面「死了」。

本文由作者按照 CC BY 4.0 进行授权