长任务、主线程占用与 Web Worker
本节目标
- 理解「长任务(Long Task)」为何造成卡顿,以及 50ms 阈值的由来
- 掌握把长任务拆片的几种手法
- 用 Node 的
worker_threads实测「把重活丢给另一个线程」
长任务(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 这个数字——它是用户感知「卡」与「不卡」的分水岭,也是你写每一段循环时该在心里拉响的警报。