前言
JS 明明号称"单线程",为什么又总听人说 Web Worker、子线程、并行计算?"单线程"和"多线程"到底哪个是真的?这一篇从最底层的主线程说起,回答五个连环问题:为什么需要子线程、Worker 是浏览器给的吗、它是子线程吗、怎么通信、以及------JS 到底还是不是单线程语言。
一、先看问题:主线程一个人忙不过来
浏览器的"主线程"是个多面手,它既要运行 JS 脚本,又要负责渲染页面、处理用户交互。一个人干三份活,只要 JS 一忙,渲染就得排队。
想象一个耗时的循环:
js
// 主线程直接跑:页面会被"冻住"
for (let i = 0; i < 5_0000_0000; i++) {
// 5 亿次计算...
}
跑这个循环的几秒里,页面点不了按钮、滚动不了、动画定格------因为主线程被计算占死了,渲染排不上队。
异步不能救这种场。 setTimeout、fetch 这类异步只是在"等待"时不占主线程,但回调一旦真正跑起来(比如回调里就是这个大循环),CPU 时间还是实打实把主线程占满。
那么问题来了:能不能把这种"真占 CPU"的活,搬到一个别的地方去跑?
二、Web Worker:浏览器提供的后台子线程
Web Worker 是 HTML5 / W3C 标准提供的浏览器 API ,不是某个框架的特性。它允许 JS 从主线程派生出一个独立的后台线程,在那个线程里跑计算任务,跑完再把结果告诉主线程。
┌─────────────┐ postMessage ┌──────────────┐
│ 主线程 │ ───────────────► │ Worker 线程 │
│ JS + 渲染 │ ◄─────────────── │ 纯计算,无UI │
│ + 用户交互 │ onmessage │ │
└─────────────┘ └──────────────┘
两条线程并行执行、互不打扰。主线程继续该渲染渲染、该响应点击响应点击,worker 在后面默默算。
关键点:
- 是浏览器提供的:HTML5 规范内置,现代浏览器原生支持,不需要装库
- 一条线一个 worker :
new Worker()一次开一个 worker 线程;一个页面可以开多个 worker(每个都是一条独立线程) - 有独立的运行环境 :worker 里有自己的
self全局对象和事件循环
三、Worker 是子线程吗:一个受限的后台线程
是子线程,但要说准确一点------它和传统多线程语言(Java、C++)里的线程不是一回事。
Worker 是由主线程 new Worker() 派生出来的,运行在浏览器单独分配的线程上,所以叫"子线程"没问题。但它有两个鲜明的"限制":
- 不能碰页面 :没有
window、没有document、不能操作 DOM,也没有 React/Vue 的组件状态 - 不共享内存:和主线程之间不共享变量,只能靠消息拷贝数据
javascript
worker 里能干的:✅ 纯计算 ✅ fetch 网络请求 ✅ indexedDB 存储 ✅ OffscreenCanvas 离屏渲染 ✅ WebAssembly
worker 里不能干的:❌ 操作 DOM ❌ 访问 window/document ❌ 改页面样式 ❌ 碰 React 状态
所以更准确的理解是:一个受限制的后台线程------能扛重活,但不能碰页面。
四、怎么创建 Worker
方式一:从 JS 文件创建(最常用)
js
const worker = new Worker('./worker.js')
Vite 工程里的写法 (import.meta.url 帮打包工具定位文件):
js
const worker = new Worker(new URL('./worker.js', import.meta.url))
方式二:从字符串创建(内联 Worker,不用单独文件)
js
const code = `self.onmessage = (e) => self.postMessage(e.data * 2)`
const blob = new Blob([code], { type: 'application/javascript' })
const worker = new Worker(URL.createObjectURL(blob))
几个注意点:
- 创建开销比较大 :每
new Worker()一次就是新开一条线程,所以通常是"创建一次、复用到底",别每次用都新建 - 必须清理 :不用的时候要
worker.terminate()关掉,否则线程一直占着资源 - 同源策略下使用 :
https环境下运行(本地localhost开发没问题)
五、怎么通信:postMessage / onmessage 双向消息
Worker 和主线程之间靠消息机制通信,两边各自有"发"和"收"两个口:
| 方向 | 发送 | 接收 |
|---|---|---|
| 主线程 → worker | worker.postMessage(数据) |
worker 里 self.onmessage = (e) => ... |
| worker → 主线程 | self.postMessage(数据) |
主线程 worker.onmessage = (e) => ... |
一条完整的数据流动:
js
// 主线程侧
const worker = new Worker(new URL('./worker.js', import.meta.url))
worker.onmessage = (e) => {
console.log(e.data.result) // ← 收 worker 发回的结果
}
worker.postMessage({ num: 88 }) // → 给 worker 派活
js
// worker.js 侧
self.onmessage = (e) => {
const { num } = e.data // ← 收主线程的任务参数
let sum = 0
for (let i = 0; i < 500; i++) sum += num * i // 干活
self.postMessage({ result: sum }) // → 发回结果
}
两个要点:
- 消息是拷贝,不是引用:传对象/数组是"结构化克隆"(structured clone),主线程改数据不影响 worker 里那份,反之亦然
- 大数据可"转移" :传
ArrayBuffer这类数据时,可以标记为 transferable,把所有权直接转给对面,零拷贝,省掉复制开销
六、适合什么场景:CPU 密集 + 不碰 DOM
不是只有"耗时计算"才能放 worker。 真正要看的判断标准是两条:
① 任务 CPU 密集吗?② 任务需要碰 DOM 吗?
- 需要碰 DOM → 只能留在主线程
- 只是 I/O 等待(fetch、定时器)→ 不用放 worker,event loop 的异步已经让主线程不阻塞了
- CPU 密集 + 不碰 DOM → 才值得放 worker
典型的 worker 场景:
| 类型 | 例子 |
|---|---|
| 纯计算 | 大循环、矩阵运算、物理/粒子模拟 |
| 加解密 | 大文件加密、哈希计算 |
| 数据处理 | 解析超大 JSON / Excel、数据清洗去重 |
| 前端推理 | LLM 在浏览器里跑(WebLLM / transformers.js) |
| 媒体处理 | 音视频编解码、OffscreenCanvas 离屏渲染、WebGL |
| 游戏逻辑 | 游戏引擎计算 |
一句话:"event loop 的异步搞不定"的 CPU 密集活,才是 worker 的主场。
七、异步 ≠ 多线程
这是最容易被绕晕的地方,分开说:
- 异步(event loop) :是"单线程内的时间调度"。等待时不阻塞,但代码最终还是在同一条主线程上跑,回调里的计算照样占满主线程
- Worker(多线程) :是"真的另开一条线程"。计算在另一条线程上跑,跟主线程并行,互不占用
css
异步:主线程 ────[等待]────[跑计算]────[等待]──── ← 计算还是会卡
Worker:主线程 ────[等待]────────────────────── ← 等待时不占
worker线程 ────[跑计算]──── ← 计算在别处跑
所以:异步解决"等待",worker 解决"计算",两者是不同层面的手段。
八、JS 到底还是不是单线程语言?------是,没变
这是最经典的一个问题。答案很明确:JS 语言本身永远是单线程的,Web Worker 不是 JS 语言的特性,是浏览器(宿主环境)给它开的线程。
分三层理解:
┌───────────────────────────────────────────────┐
│ 浏览器(C++ 写的,本质是多进程多线程的软件) │
│ 渲染进程 / GPU 进程 / 网络进程 / ... │
│ │
│ ┌───────┐ ┌───────┐ │
│ │ 主线程 │ 独立 │ worker │ ← 浏览器开的线程 │
│ │ JS 引擎│ 内存 │ JS 引擎│ │
│ │ 事件循环│ 互不共享 │ 事件循环 │ │
│ └───────┘ └───────┘ │
└───────────────────────────────────────────────┘
- JS 语言规范(ECMAScript)定义的就是单线程执行模型:没有多线程、没有共享内存、没有锁
- 每个 worker 内部也是单线程的:它是另一个独立的 JS 运行环境,有自己的事件循环
- worker 的线程是浏览器开的 ,不是 JS 语言开的。JS 只是提供了
new Worker()这个"叫浏览器干活"的接口
所以说更准确:"多个单线程"并行 ,而不是"一个多线程"。这也是为什么 worker 和主线程之间不需要锁------它们根本不共享内存,只能靠消息拷贝传数据,天然没有数据竞争。
补充一个用词:浏览器里 new Worker() 出来的是子线程 (同一个渲染进程内的线程);"子进程"是 Node 里 child_process / cluster 的概念,级别不同,别混用。
小结
- 主线程既要跑 JS 又要渲染,CPU 密集任务会卡死页面,异步(event loop)解决不了"计算"问题
- Web Worker 是浏览器(HTML5/W3C)提供的后台子线程 API ,
new Worker()创建 - Worker 是受限的子线程 :不能碰 DOM/window/document,和主线程不共享内存,只能靠
postMessage/onmessage消息通信(拷贝传数据) - 适合场景判断标准:CPU 密集 + 不碰 DOM;耗时计算是典型场景,但不是唯一
- JS 语言仍是单线程的:worker 只是浏览器(宿主环境)额外开的独立执行环境,本质是"多个单线程"并行
- 记得
worker.terminate()清理线程,别开完不管
一句话:Worker 是浏览器借给 JS 的一条"干重活的胳膊"------活搬走了,手还是那只手(单线程),只是多了个帮手。