JavaScript 在浏览器里最核心的一个特点,就是它的主执行环境通常是单线程的。 这意味着如果你在主线程里做了一个很重的任务,比如大文本解析、图片处理、复杂计算,页面的点击、滚动、输入框响应都有可能一起卡住。
所以浏览器提供了 Web Worker 这个机制。它的作用很直接:
TIPS:Web Worker 不是为了“让异步代码更异步”,而是为了把会占用主线程时间的工作挪走。像普通的 fetch 本身就已经是异步的,但它后续的数据处理、解析、状态管理,仍然可能对主线程产生影响。
先看一个最简单的例子。下面这段代码会阻塞主线程:
button.addEventListener("click", () => { console.log("开始计算");
let total = 0; for (let i = 0; i < 1e9; i++) { total += i; }
console.log("计算完成", total);});这段代码执行时:
原因很简单:JavaScript 主线程正在忙着算这件事,没空处理别的任务。
而 Web Worker 的思路就是把这种任务搬到后台去:
你可以把 Web Worker 理解成:
浏览器提供的一段“后台 JavaScript 运行环境”。
它和主线程是隔离的,有几个重要特点:
window、documentfetchpostMessage 和主线程通信这一点非常关键。因为它不能碰 DOM,所以它不适合写页面逻辑;但正因为隔离,它特别适合做纯计算和纯数据处理。
const worker = new Worker("./worker.js");如果你在现代构建工具里使用它,常见写法会是:
const worker = new Worker(new URL("./worker.js", import.meta.url));这种写法在 Vite、Webpack、Next.js 的某些构建场景里更稳定,因为它能让打包器正确识别这个 Worker 文件。
worker.postMessage({ type: "START_CALC", payload: { count: 1000000 },});self.addEventListener("message", (event) => { const { type, payload } = event.data;
if (type === "START_CALC") { let total = 0; for (let i = 0; i < payload.count; i++) { total += i; }
self.postMessage({ type: "CALC_DONE", payload: { total }, }); }});worker.addEventListener("message", (event) => { const { type, payload } = event.data;
if (type === "CALC_DONE") { console.log("结果:", payload.total); }});这个模式其实非常固定:
一般来说,下面这些场景比较适合:
如果只是一个很轻的小函数,通常没必要上 Worker。因为创建 Worker、消息通信、数据拷贝本身也是有成本的。
Worker 里不能这样写:
document.querySelector("#app").textContent = "Hello";因为 Worker 根本拿不到 document。
如果任务本身很轻,主线程直接做反而更简单。因为:
比如直接操作 DOM、读取元素尺寸、绑定点击事件,这些都应该留在主线程。
这里有一个很容易混淆的点。
很多初学者会觉得:
async/await 是异步fetch 是异步这个说法不完全对。
async/await 解决的是等待结果时不要把代码写成回调地狱。
但 Web Worker 解决的是不要让主线程去承担重任务。
换句话说:
Promise / async 主要解决代码组织问题Web Worker 主要解决线程占用问题这两个概念可以一起用,而且经常一起用。
我的AHF-QDS-EOE项目里就用了 Web Worker 来处理 AI 转换请求,相关代码在:
src/services/conversionService.tssrc/workers/conversion.worker.ts整体思路是这样的:
conversionServiceconversionService 把请求发给 conversion.worker.tsfetch对应的主线程代码大致是:
this.worker!.postMessage({ type: "CONVERT_AI", payload: { ...request, requestId, },});Worker 里则监听消息并执行请求:
self.addEventListener("message", async (event) => { const { type, payload } = event.data;
if (type === "CONVERT_AI") { const { baseUrl, apiKey, model, messages, requestId } = payload;
const response = await fetch(`${baseUrl}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model, messages, temperature: 0.7, }), });
const data = await response.json();
self.postMessage({ type: "CONVERT_SUCCESS", payload: { content: data.choices?.[0]?.message?.content || "", requestId, }, }); }});这个设计有两个直接好处:
主线程和 Worker 之间传的是消息,不是共享普通变量。 如果你传超大的对象,通信成本会明显增加。
type像这样:
worker.postMessage({ type: "PARSE_TEXT", payload: text,});这样做的好处是后面任务一多,通信协议不会乱。
requestId我的项目里就用了 requestId。
这样当多个任务并发执行时,主线程才能知道哪个结果对应哪个请求。
Worker 内部也可能失败,比如:
所以应该把错误也通过消息返回,而不是只处理成功路径。
如果你遇到下面这些情况,就可以认真考虑:
如果只是普通表单提交、轻量数据处理、简单状态更新,一般不用急着上 Worker。
前端开发里一个常见误区就是:看到“异步”就想上 Worker,或者看到“性能”就想上 Worker。其实应该先判断瓶颈到底在哪里。
Web Worker 的本质并不复杂。
你可以把它理解成:
它最适合的场景,是那些不需要操作页面、但又可能拖慢页面响应的任务。
而在实际项目里,Web Worker 往往不会单独出现。它经常会和下面这些技术搭配:
Promise / async-await这也是为什么在我的AHF-QDS-EOE项目里,最终能形成一种比较稳的体验:不是因为只用了 Web Worker,而是因为 Worker 和持久化状态一起工作了。
如果你刚开始学前端,我比较建议先把 postMessage 这一套通信模型吃透,再去看项目里的封装代码。这样你会更容易理解为什么很多工程代码表面上看起来复杂,但底层其实就是“发消息,处理消息,再把结果发回来”。