长任务、主线程占用与 Web Worker

本节目标

长任务(Long Task):主线程连续执行超过 50ms 的任务。浏览器在此期间无法渲染、无法响应输入 → 表现为卡顿。(TBT 这个性能指标就是长任务的累加。)

为什么是 50ms:一帧约 16.6ms,连续占满约 3 帧用户就感知到卡。50ms 是一个「留有余地」的经验阈值。

拆分任务(让渲染插队):

// ❌ 一次性处理 10 万条(一个长任务,卡死)
bigList.forEach(process)

// ✅ 分片:每片让出一帧(配合 setTimeout(0) 或 postMessage)
const CHUNK = 1000
let i = 0
function step() {
  const end = Math.min(i + CHUNK, bigList.length)
  for (; i < end; i++) process(bigList[i])
  if (i < bigList.length) setTimeout(step, 0)   // 让渲染/交互插队
}
step()

现代方案 scheduler.yield()(Chrome 支持):await scheduler.yield() 主动让出主线程,比 setTimeout(0) 调度更合理(不会被压到最低优先级)。

Web Worker(真正的并行):把重计算丢到独立线程,不占主线程。下面这段 Node 版 Worker 示例可真实运行,演示「主线程不被阻塞」:

// 运行环境:Node.js 14+(使用内置 worker_threads,无需装包)
// 步骤:把下面内容存为 brw-l10.js,执行  node brw-l10.js
// 你会看到:主线程持续打印 "主线程还在干活",同时 Worker 在并行算斐波那契,直到 Worker 完成

const { Worker } = require('node:worker_threads')

// Worker 线程里跑的重活(这里是耗时递归)
const workerCode = `
  const { parentPort } = require('node:worker_threads')  // eval 模式下 parentPort 不是全局,需手动引入
  function fib(n){ return n < 2 ? n : fib(n-1)+fib(n-2) }
  const start = Date.now()
  const r = fib(40)                 // 够重、又不会等太久(约 1 秒量级)
  parentPort.postMessage({ result: r, ms: Date.now()-start })
`
const w = new Worker(workerCode, { eval: true })

// 主线程同时干别的事,不被 Worker 拖住(一直「有心跳」直到 Worker 完成)
let t = 0
const id = setInterval(() => {
  t += 100
  console.log('主线程还在干活...', t, 'ms')
}, 100)

w.on('message', (m) => {
  clearInterval(id)   // Worker 完成后停掉主线程的心跳
  console.log(`Worker 算完 fib(40)=${m.result},耗时 ${m.ms}ms(主线程全程没被卡)`)
  w.terminate()
})

Worker 的限制(面试点):没有 DOM(不能操作 document/window);只能靠 postMessage 通信(结构化克隆,大对象可用 Transferable 零拷贝转移);有同源限制;可开多个(受内存限制)。SharedWorker 多页面共享,Service Worker 是网络代理层(见存储章节)。

适用场景:图像处理、大数据计算、加密、音视频转码、大 JSON 解析。

名词解释

长任务(Long Task):主线程连续占用的时长超过 50ms 的任务。它的「判定方法」是浏览器性能 API:new PerformanceObserver(list => {...}).observe({ type: 'longtask', buffered: true }),超过阈值的任务会被上报。长任务多 = 用户觉得「卡」,对应指标 TBT(总阻塞时间)。

任务分片(Task Chunking):把一个大任务切成小块、块与块之间让出主线程的技术。方法:setTimeout(fn, 0)(排到下一轮宏任务)、postMessage 自派发、scheduler.yield()(现代 API 主动让出)。本质都是「别一次性霸占主线程」。

Web Worker:浏览器/Node 里的「独立线程」,能跑 JS 但不碰 DOM。方法:new Worker(url) 创建、worker.postMessage(data) 发消息、worker.onmessage 收消息、worker.terminate() 终止。它把「计算」和「界面」拆到不同线程,是前端对抗长任务最有效的大招(小活用分片,大活用 Worker)。

课后练习

练习 1:scheduler.yield() 和 setTimeout(fn, 0) 都能「让出主线程」,为什么前者更受推荐?

答案:语义和优先级不同。setTimeout(0) 把回调排到「下一个宏任务队列末尾」,可能被大量其他宏任务(如其他 setTimeout、I/O 回调)插队,导致分片后的活迟迟轮不到;scheduler.yield() 是专门设计的「让出控制信号」,告诉调度器「我主动让一下,但请把后续尽快排上」,对保持响应更友好,也不会被压到最低优先级。

练习 2:下面哪种场景适合用 Web Worker,哪种不适合?A) 实时搜索框输入联想(每次按键都要改 DOM 显示结果);B) 前端压缩 500MB 用户上传的视频。

答案:A 不适合——搜索联想要频繁改 DOM,而 Worker 没有 DOM,且输入响应要求极低延迟,分片即可;B 适合——视频压缩是纯计算重活,放主线程会卡死整个页面,丢给 Worker 既能并行又不阻塞界面。判断标准:「要不要操作 DOM」「重不重」「能不能序列化传数据」。

总结

这一节是「事件循环」的实战落点。理论说了半天「让渲染插队」,到这里终于给出具体武器:小活用任务分片(setTimeout/yield),大活用 Web Worker。我的核心观点是——前端性能的很多痛苦,源于我们习惯性地把「所有事都堆在主线程这条单行道上」。主线程既要跑 JS、又要解析、又要布局绘制、还要响应点击,它已经够忙了。所以优秀的前端不是「把代码写得多快」,而是「懂得把主线程让出来」:能分片的别一把梭,能并行的丢给 Worker,连动画都走合成线程。记住 50ms 这个数字——它是用户感知「卡」与「不卡」的分水岭,也是你写每一段循环时该在心里拉响的警报。