封面

Web Workers 完全指南:用多线程解决 JavaScript 页面卡死

原文:https://dev.to/parsajiravand/web-workers-in-javascript-the-complete-guide-5f17(作者 @parsajiravand)

在一个正用 JavaScript 解析 5 万行 CSV 的页面上拖动滑块,接下来整整一秒页面毫无反应。这不是因为滑块的代码慢——它压根没机会运行。浏览器唯一的 JavaScript 线程正忙着执行你的解析循环,在这个循环返回之前,点击、滚动、重绘统统排不上队。而解决方案早已内置在你所支持的每一款浏览器里:再开一条线程,也就是 Web Worker,让它来运行你的代码,完全不碰页面 UI 所依赖的那条线程。

你将学到什么

读完本指南后,你将能够:

  • 解释浏览器为什么只有唯一一条线程同时负责 JavaScript 和 DOM,以及为什么高负载下这条线程会让整个页面卡住
  • 创建 Web Worker、向它发送数据并取回结果,全程不阻塞主线程
  • 正确理解哪些数据能在线程之间来回传递、哪些不能(structured cloning 结构化克隆 vs. transferable objects 可转移对象)
  • 处理 worker 的错误、干净地终止 worker,并避免因忘记终止而带来的内存泄漏
  • 有理有据地判断什么时候值得引入 worker 的复杂度,什么时候不值得

适合谁读: 你写过 addEventListener 事件处理器、用过 fetch,也遇到过说不清原因的 UI 卡顿。

目录

Web Workers 为什么存在

浏览器里的 JavaScript 运行在一条线程上——也就是主线程(main thread)——而同一条线程还要负责解析 HTML、计算样式、布局与绘制像素、响应用户输入。你的代码一旦运行,其他一切都得等着。下面就是开头那个 CSV 场景的朴素实现:

// 错误做法——这会阻塞页面上的其他一切
function sumColumn(rows, columnIndex) {
  let total = 0;
  for (let i = 0; i < rows.length; i++) {
    total += Number(rows[i][columnIndex]);
  }
  return total; // 对于 50,000+ 行数据,这一步轻松耗时 500ms–2s
}

button.addEventListener("click", () => {
  const total = sumColumn(hugeDataset, 3); // 主线程在这里卡住
  resultEl.textContent = total;
});


sumColumn 运行期间,浏览器无法重绘、无法触发滚动或点击处理器,也无法更新屏幕上的任何东西——包括你刚刚才显示出来的加载转圈。标签页看起来冻住了,因为在那段时间里它确实就是冻住的。setTimeout(fn, 0) 也帮不上忙:回调依然跑在同一条主线程上,只是稍微晚一点执行;它只能推迟卡死,不能消除卡死。

Web Worker 的解法是:给这个昂贵的循环一条专属线程,配上独立的 JavaScript 引擎实例,与主线程并行运行。整个过程中,主线程始终有空去绘制页面、响应输入。

心智模型

心智模型: Web Worker 是一个并行运行的独立 JavaScript 环境,与页面不共享任何内存——数据进出只有一条路:通过消息通道发送副本。

可以想象两个房间:没有共用的家具,中间也没有窗户,唯一的连接是一条投信口。你可以把纸条塞进投信口(postMessage),另一个房间读完后再回寄一张(onmessage)。任何一方都无法把手伸进对方房间去抓一个变量、调用一个函数、或碰一下放在对方房间里的 DOM 元素——因为实际上什么都不共享。穿过投信口的是数据的副本,由一种叫结构化克隆(structured cloning)的算法生成,而不是指向原始数据的引用。

仅这一个事实——没有共享内存,只有复制的消息——几乎可以解释后文的每一条规则:为什么 worker 碰不了 DOM(DOM 对象住在主线程那个房间里)、为什么你不能把函数传给 worker(函数不可克隆)、以及为什么超大负载需要另一种技巧(可转移对象,transferable objects,后文会讲)。

逐步搭建一个 worker

阶段 1:最小可运行的 worker

worker 需要从一个单独的脚本文件来创建:

// main.js
const worker = new Worker("sum-worker.js");

worker.postMessage({ rows: hugeDataset, columnIndex: 3 }); // 发送一份数据副本

worker.onmessage = (event) => {
  resultEl.textContent = event.data.total; // 在 worker 回复后执行
};


// sum-worker.js —— 在独立线程上运行
self.onmessage = (event) => {
  const { rows, columnIndex } = event.data;
  let total = 0;
  for (let i = 0; i < rows.length; i++) {
    total += Number(rows[i][columnIndex]);
  }
  self.postMessage({ total }); // 以消息形式把结果发回去
};


核心概念: worker 文件无法访问 window 和 DOM——在它内部,self 指向的是 worker 自己的全局作用域(DedicatedWorkerGlobalScope),而不是页面。

现在点击按钮会把数据发给 worker 然后立刻返回,主线程完全不会被阻塞。繁重的循环跑在 worker 的线程上,页面全程都能继续渲染、继续响应输入。

阶段 2:不借助单独文件来加载 worker

有时你并不想为了一个很小的 worker 脚本再发起一次网络请求。这时可以用 Blob 和对象 URL,从字符串构建出一个 worker:

const workerSource = `
  self.onmessage = (event) => {
    const n = event.data;
    self.postMessage(fib(n));
  };
  function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
`;

const blob = new Blob([workerSource], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));


下文的在线演示就是用这种方式内联构建 worker 的——适合做演示、写小型工具型 worker,或者供那些不想额外部署一个文件的库使用。

阶段 3:模块 worker

经典 worker 用的是较早的 importScripts() 函数来加载依赖。现代浏览器还支持模块 worker,也就是使用标准 import 语句的 worker,只需在创建时传入 { type: "module" }

const worker = new Worker("sum-worker.js", { type: "module" });


// 以模块形式编写的 sum-worker.js
import { sumColumn } from "./math-utils.js";

self.onmessage = (event) => {
  self.postMessage(sumColumn(event.data.rows, event.data.columnIndex));
};


截至 2026 年,所有当前主流浏览器都已支持模块 worker。如果你需要兼容不支持模块 worker 的旧环境,就继续用经典 worker 和 importScripts()

阶段 4:可转移对象,当复制成为瓶颈时

结构化克隆是复制数据。对于一个只装着几个数字的普通对象来说,复制几乎不花时间。但对于一个装着音频或图像数据、体积达 200 MB 的 ArrayBuffer,每条消息都复制一遍就成了新的瓶颈。解决办法是可转移对象(transferable object):不是把 ArrayBuffer 复制一份,而是把它的所有权转移给 worker。

const buffer = new ArrayBuffer(1024 * 1024 * 50); // 50 MB
worker.postMessage(buffer, [buffer]); // 第二个参数:转移列表

// 这次调用之后,主线程上 `buffer.byteLength` 变为 0 ——
// 这块内存现在属于 worker,不再属于当前线程


核心概念: 转移是把底层内存整体挪走而不是复制它,所以哪怕缓冲区再大也几乎零开销——但这也正是转移之后原来的引用会失效的原因。

ArrayBufferMessagePortImageBitmap 以及部分流类型是可转移的;普通对象、数组和字符串不行——它们只能被复制。

阶段 5:清理与终止

worker 会一直运行(并一直占着内存),直到你显式地让它停下:

// 在主线程中调用
worker.terminate(); // 立即停止 worker,无论它此刻在做什么

// 在 worker 内部调用
self.close(); // worker 请求自行停止,会在完成当前工作后执行


terminate() 是立即且无条件的——worker 手头正在做的任何工作都会被直接丢弃,无法保证 finally 块会执行。对于不再需要的 worker(比如组件卸载时),一定要记得终止它,否则它会一直运行、一直占用内存,直到页面关闭。

边界情况与坑

  • 永远无法访问 DOM。 Worker 既不能读写 document,也不能使用 window,更不能操作你传给它的任何 DOM 节点——试图发送一个 DOM 节点会抛出 DataCloneError,因为 DOM 节点无法被结构化克隆。如果 worker 需要渲染某些内容,它会计算好数据再发回给主线程去绘制(或者使用 OffscreenCanvas,那是一个独立的、更高级的 API)。
  • 函数无法跨越边界。 你不能通过 postMessage 传一个回调函数让 worker 去调用。只能数据进、数据出;worker 自己的脚本决定如何处理这些数据。
  • 报错的位置和你想的不一样。 worker 内部未捕获的异常并不会在主线程上抛出——它会在 Worker 对象上触发一个 error 事件。务必挂载一个处理函数:

`js

worker.onerror = (event) => {

console.error("Worker crashed:", event.message, event.filename, event.lineno);

};

`

没有这个处理函数的话,一个抛了异常的 worker 在主线程看来就只是悄无声息地没了动静。

  • 同源限制。 传统方式的 new Worker(url) 脚本必须与页面同源(页面自己创建的 blob: URL 在这里也算同源)。你不能让 worker 直接指向第三方脚本的 URL。
  • 启动 worker 并非零成本。 拉起一个 worker 有实打实的开销——分配线程、创建新的 JS 引擎上下文、加载脚本。对于一个几毫秒就能跑完的任务,这些开销可能比任务本身还大。worker 适合量大或反复执行的工作,不适合琐碎的一次性计算。
  • worker 可以再派生自己的 worker,也能使用 fetchWebSocketsetTimeoutIndexedDB——它是一个真正的 JavaScript 环境,只是没有 DOM 而已。
  • `SharedWorker` 是另一套不太常见的 API。 它允许同源的多个标签页通过 port.postMessage 连接到同一个共享的 worker 实例,而不是每个标签页各自拥有专属 worker。依赖它之前先确认当前的浏览器支持情况——它的支持度历来落后于 Workertype: "module"

最佳实践

在这些场景下用 worker:当你要做耗时几十毫秒以上的 CPU 密集型工作——解析大文件、执行压缩、图像或音频处理、复杂的数据变换、加密运算,或者对内存中的大型数据集做搜索/过滤——尤其是当这些工作需要在用户持续操作页面的同时进行时。

在这些场景下避开它:当工作本身已是 I/O 密集型(即使没有 worker,fetch 调用也不会阻塞主线程——等待网络的环节本来就在别的线程进行);当涉及的数据小到结构化克隆的成本比计算本身还高;或者当任务足够短、频率足够低,以至于 worker 的启动成本占了主导。

保持消息契约简洁。 把 worker 的消息设计成一个小型 API:一个 type 字段加一个 payload,双向都照这个来。这样即使 worker 要处理多种任务,结构也能保持清晰。

worker.postMessage({ type: "PARSE_CSV", payload: csvText });
worker.postMessage({ type: "SORT_ROWS", payload: { rows, key: "date" } });


创建必配清理。 每一个 new Worker(...) 都应该有对应的 terminate()——可以放在组件的卸载/清理钩子里、放在“取消”按钮中,或者放在任务自然完成且你不再打算复用 worker 的时候。

常见问题

Web Worker 能访问 DOM 吗?

不能。Worker 运行在一个完全没有 DOM API 的上下文里——没有 document,也没有 window。如果 worker 需要渲染什么,它会把数据发回主线程,由主线程执行实际的 DOM 更新。

Web Worker 运行时会阻塞主线程吗?

不会——这恰恰是它们存在的意义。worker 的代码运行在自己的线程上,与主线程并行,所以即便 worker 忙得不可开交,页面依然能继续绘制和响应输入。

能在 worker 里使用 fetchWebSocket 吗?

能。Worker 可以使用 fetchWebSocketsetTimeout/setIntervalIndexedDBself.crypto 等一系列 API。它们缺的只是所有与 DOM 相关的东西。

Web Worker 和 Service Worker 有什么区别?

Worker 由单个页面创建和持有,用来分担计算任务,并随该页面的关闭而销毁(除非你使用 SharedWorker,它可以让多个标签页连接)。`ServiceWorker` 则完全是另一套 API:它按源(origin)注册,独立于任何打开的标签页持续运行,主要用于拦截网络请求、实现离线支持和推送通知。二者解决的问题不同,不能互相替代。

postMessage 是复制我的数据,还是传引用?

默认是复制,使用的是结构化克隆算法——接收方拿到的是一份独立的副本,之后无论哪一方做了修改都不会影响另一方。例外情况是你在转移列表(transfer list)中显式列出的对象(比如 ArrayBuffer),它们会被转移而非复制。

能在 worker 里使用 React、Vue 或其他框架的代码吗?

纯 JavaScript 逻辑(数据变换、计算、解析)在 worker 里运行完全没问题,但你无法在里面渲染框架组件,因为渲染归根结底要操作 DOM,而 worker 无法访问 DOM。让 worker 负责计算,让主线程负责渲染。

速查表

  • 创建 workernew Worker("file.js") —— 脚本必须同源(或使用 blob: URL)
  • 创建模块 workernew Worker("file.js", { type: "module" }) —— 允许 worker 使用 import
  • 向 worker 发送数据worker.postMessage(data) —— 通过结构化克隆复制 data
  • 转移而非复制worker.postMessage(buf, [buf]) —— 仅适用于可转移类型(如 ArrayBuffer);转移后 buf 在发送方一侧随即失效
  • 接收数据(主线程)worker.onmessage = (e) => e.data
  • 接收数据(worker 内部)self.onmessage = (e) => e.data —— self 即 worker 的全局作用域
  • 从 worker 回复消息self.postMessage(result)
  • 处理 worker 崩溃worker.onerror = (e) => {...} —— worker 内未捕获的异常会在这里浮出,而不是以抛出错误的形式出现
  • 停止 worker(从外部)worker.terminate() —— 立即生效;worker 内部的清理代码不会执行
  • 停止 worker(从内部)self.close() —— worker 会完成并退出
// 完整模式,可直接复制粘贴使用
const worker = new Worker("worker.js");

worker.postMessage({ type: "RUN", payload: someData });

worker.onmessage = (event) => {
  console.log("Result:", event.data);
};

worker.onerror = (event) => {
  console.error("Worker error:", event.message);
};

// 之后用完了,就终止它
worker.terminate();


<!-- playground:start -->

动手试试

[打开交互式 Playground →](https://bestpractic.org/blog/weekly-web-workers/playground)

直接在浏览器里运行——随便拨弄几下,看着这个概念实时响应。

<!-- playground:end -->

核心要点

  • 浏览器只有一条线程负责 JavaScript 和 DOM;在这条线程上做的任何昂贵操作都会拖住绘制、滚动和点击。
  • Web Worker 是一条没有共享内存的独立线程——数据通过 postMessage 传递,由结构化克隆算法负责复制。
  • 函数和 DOM 节点永远过不了这条边界;能过去的只有可克隆的数据,除非你显式转移 ArrayBuffer 这类受支持的类型。
  • 始终挂上 onerror,用完的 worker 始终要 terminate()——没被清理的 worker 会一直运行、一直占着内存。
  • Worker 的开销只有在实打实的 CPU 密集型任务上才花得值,对那些本来就未曾阻塞主线程的小任务或 I/O 密集型任务并不划算。

<!-- quiz:start -->

自测一下

感觉都理解了?[来做这 8 道测试题 →](https://bestpractic.org/blog/weekly-web-workers/quiz)

即时反馈,每道题都有提示,每个答案(无论对错)都有解析。

<!-- quiz:end -->

开头提到的那个滑块,完全可以在一模一样的 5 万行解析任务中保持流畅——把循环挪进 worker,用 postMessage 把行数据发过去,主线程就再也没有卡顿的理由。你自己的代码库里,最重的那个循环是哪一段?它是不是还在 UI 能感觉到的地方跑着?

原文:https://dev.to/parsajiravand/web-workers-in-javascript-the-complete-guide-5f17(作者 @parsajiravand)

发布评论
全部评论(0)