浏览器里的多线程:一文搞懂 Web Worker 全家桶
JavaScript 是一个"单线程"的语言。这句每个前端都听过的话,实际意味着什么?
打开一个网页,JS 在主线程上跑。渲染页面、响应用户点击、执行动画——所有这些事都挤在同一条流水线上。一旦某段 JS 算得太久(比如处理一个大 JSON、做图片滤镜、或者跑一个复杂循环),整条流水线就卡住了。按钮点不动、滚动不跟手、动画掉帧——用户看到的就是"页面卡死了"。
这就是 Web Worker 存在的意义。它让你在主线程之外另起一条"分流水线",把重活丢给这条分流水线,主线程继续响应用户操作。下面我们把 Worker 家族逐个拆开看。
Dedicated Worker:最基础的"分流水线"
Dedicated Worker(专用 Worker)是最朴素的形态。一个页面创建一个 Worker,这个 Worker 只服务这一个页面。
// 主线程
const worker = new Worker('worker.js');
// 给 Worker 发消息
worker.postMessage({ type: 'calculate', data: hugeArray });
// 收 Worker 的回信
worker.addEventListener('message', (e) => {
console.log('Worker 算完了:', e.data);
});
Worker 里没有 window,没有 document。你不能在里面操作 DOM——这其实是好事,因为它天然避免了和主线程抢 DOM 的问题。Worker 的全局对象是 self:
// worker.js
self.addEventListener('message', (e) => {
const result = heavyCalculation(e.data);
self.postMessage(result);
});
如果需要引入其他脚本,Worker 里不能用 <script> 标签,而是用 importScripts:
importScripts('helper.js', 'utils.js');
// 现在 helper 和 utils 里导出的东西可以直接用了
importScripts 是同步的,会按顺序加载完再往下执行。
SharedWorker:让多个页面共用一条 Worker
Dedicated Worker 是"一对一"的关系——一个页面配一个 Worker。SharedWorker 则是"多对一":同源的多个标签页共享同一个 SharedWorker 实例。
这有什么用?最简单的场景:一个音乐播放器,你开了两个标签页。如果用 SharedWorker 管理播放状态,两个标签页看到的状态是一致的——暂停就是真暂停,不用在每个页面各管各的。
// 主线程(A 页面和 B 页面写一样的代码)
const worker = new SharedWorker('shared-worker.js');
// SharedWorker 通信需要通过 port,并且必须手动启动
worker.port.start();
worker.port.postMessage('hello from page A');
worker.port.addEventListener('message', (e) => {
console.log('SharedWorker 回信:', e.data);
});
只要至少有一个同源页面还连着,SharedWorker 就活着;所有关联页面都关闭后,它才会被销毁。判断"是不是同一个"SharedWorker 的依据是脚本路径——A 页面和 B 页面必须用完全相同的 URL 来 new SharedWorker(),才会连到同一个实例。
Service Worker:独立于页面的"常驻代理"
Service Worker 和前面两个有本质区别。它不依附于任何一个具体页面——即使所有页面都关了,它依然可以活着(浏览器会根据需要决定是否休眠)。
它的核心作用是拦截网络请求。你去访问一个 URL,Service Worker 可以在这个请求发出去之前截住它,然后决定:是从缓存里拿,还是继续发出去。这就是 PWA 离线可用的基础。
生命周期
Service Worker 有自己的"生命周期",和页面生命周期完全是两回事:
- install:第一次注册时触发。通常在这里预缓存关键资源(HTML、CSS、JS、首屏图片)
- activate:安装完成后触发。同一个脚本再次注册不会重新 install,而是走 activate
- fetch:当前端发出网络请求时触发——这是 Service Worker 最核心的拦截点
缓存资源
Service Worker 自带一个 caches 全局对象(不能用 localStorage),用来做缓存:
// 预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll([
'/',
'/index.html',
'/style.css',
'/app.js',
]);
})
);
});
// 拦截请求,优先返回缓存
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
return cachedResponse || fetch(event.request);
})
);
});
这就是经典的 Cache First 策略:先查缓存,命中直接返回;没命中才走网络。被缓存过的请求返回 304,浏览器知道不用重新下载。
更新控制
Service Worker 有自己的更新机制,两个关键 API:
- skipWaiting():新安装的 Service Worker 不等待旧版释放,立即激活
- clients.claim():激活后立即接管所有受控页面,不用等用户刷新
这两者通常搭配使用,让新版 Service Worker 无缝上线。
Service Worker 还有一个很实用的 API——self.clients.matchAll(),可以拿到当前受控的所有页面,用来做广播通知之类的操作。
Node.js 里的 Worker:服务端的多线程
Node.js 这边也有 Worker,但动机完全不同。浏览器 Worker 是为了不卡 UI;Node.js 没有 UI,Worker 是为了利用多核 CPU。
Node.js 10+ 通过 worker_threads 模块引入:
// 主线程
const { Worker } = require('worker_threads');
const worker = new Worker('./worker.js', {
workerData: 40 // 直接把初始数据传进去
});
worker.on('message', (msg) => {
console.log('子线程结果:', msg);
});
子线程里的写法也略有不同,需要从 worker_threads 解构出 workerData 和 parentPort:
// worker.js
const { workerData, parentPort } = require('worker_threads');
let num = workerData; // 40
let result = fibonacci(num);
parentPort.postMessage(result);
和浏览器 Worker 的关键区别:Node.js Worker 可以传初始数据(workerData),而且通信路径是 parentPort 而非 self。
一张表总结
| 类型 | 作用域 | 生命周期 | 核心用途 |
|---|---|---|---|
| Dedicated Worker | 单个页面 | 页面关闭即销毁 | 密集计算、数据处理 |
| SharedWorker | 同源多页面 | 所有关联页面关闭后销毁 | 多标签页共享状态 |
| Service Worker | 同源所有页面 | 独立于页面,浏览器管理 | 离线缓存、请求代理 |
| Node.js Worker | 单个进程 | 主线程结束即销毁 | CPU 密集型任务、多核利用 |
一个容易踩的坑
Worker 里拿不到 DOM,也拿不到 window。这不是 bug,是设计。初学者经常在 Worker 里写 document.querySelector() 然后纳闷为什么报错——如果你需要在 Worker 计算完后更新 DOM,正确的做法是把结果 postMessage 回主线程,让主线程去操作 DOM。
理解了这个"主线程—Worker 线程"的消息通信模型,Worker 的用法就通了。