Web Worker:浏览器给 JS 开的后台子线程

前言

JS 明明号称"单线程",为什么又总听人说 Web Worker、子线程、并行计算?"单线程"和"多线程"到底哪个是真的?这一篇从最底层的主线程说起,回答五个连环问题:为什么需要子线程、Worker 是浏览器给的吗、它是子线程吗、怎么通信、以及------JS 到底还是不是单线程语言。

一、先看问题:主线程一个人忙不过来

浏览器的"主线程"是个多面手,它既要运行 JS 脚本,又要负责渲染页面、处理用户交互。一个人干三份活,只要 JS 一忙,渲染就得排队。

想象一个耗时的循环:

js 复制代码
// 主线程直接跑:页面会被"冻住"
for (let i = 0; i < 5_0000_0000; i++) {
  // 5 亿次计算...
}

跑这个循环的几秒里,页面点不了按钮、滚动不了、动画定格------因为主线程被计算占死了,渲染排不上队。

异步不能救这种场。 setTimeoutfetch 这类异步只是在"等待"时不占主线程,但回调一旦真正跑起来(比如回调里就是这个大循环),CPU 时间还是实打实把主线程占满。

那么问题来了:能不能把这种"真占 CPU"的活,搬到一个别的地方去跑?

二、Web Worker:浏览器提供的后台子线程

Web Worker 是 HTML5 / W3C 标准提供的浏览器 API ,不是某个框架的特性。它允许 JS 从主线程派生出一个独立的后台线程,在那个线程里跑计算任务,跑完再把结果告诉主线程。

复制代码
┌─────────────┐    postMessage     ┌──────────────┐
│   主线程      │ ───────────────►  │  Worker 线程   │
│  JS + 渲染   │   ◄─────────────── │  纯计算,无UI  │
│  + 用户交互   │    onmessage      │               │
└─────────────┘                    └──────────────┘

两条线程并行执行、互不打扰。主线程继续该渲染渲染、该响应点击响应点击,worker 在后面默默算。

关键点:

  • 是浏览器提供的:HTML5 规范内置,现代浏览器原生支持,不需要装库
  • 一条线一个 workernew Worker() 一次开一个 worker 线程;一个页面可以开多个 worker(每个都是一条独立线程)
  • 有独立的运行环境 :worker 里有自己的 self 全局对象和事件循环

三、Worker 是子线程吗:一个受限的后台线程

是子线程,但要说准确一点------它和传统多线程语言(Java、C++)里的线程不是一回事。

Worker 是由主线程 new Worker() 派生出来的,运行在浏览器单独分配的线程上,所以叫"子线程"没问题。但它有两个鲜明的"限制":

  1. 不能碰页面 :没有 window、没有 document、不能操作 DOM,也没有 React/Vue 的组件状态
  2. 不共享内存:和主线程之间不共享变量,只能靠消息拷贝数据
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)提供的后台子线程 APInew Worker() 创建
  • Worker 是受限的子线程 :不能碰 DOM/window/document,和主线程不共享内存,只能靠 postMessage/onmessage 消息通信(拷贝传数据)
  • 适合场景判断标准:CPU 密集 + 不碰 DOM;耗时计算是典型场景,但不是唯一
  • JS 语言仍是单线程的:worker 只是浏览器(宿主环境)额外开的独立执行环境,本质是"多个单线程"并行
  • 记得 worker.terminate() 清理线程,别开完不管

一句话:Worker 是浏览器借给 JS 的一条"干重活的胳膊"------活搬走了,手还是那只手(单线程),只是多了个帮手。

相关推荐
计算机魔术师1 小时前
数据分析还在靠人跑 SQL?AI 智能体已经把效率拉到 63 倍
前端
瑞码空间1 小时前
Routing & API:前后端协作的本质与实现
前端·后端·接口·路由
不好听6131 小时前
React 的 useRef vs useState:响应式与非响应式的分界线
前端·react.js
我真是泰库辣1 小时前
用TraeWork制作应用 —— 从 0 到 1 · 手把手搭建 opencode 网页对话网关
前端·后端
RobinDevNotes1 小时前
轻量级动画引擎Anime.js迎来V4大版本更新
开发语言·前端·javascript·ecmascript·动画·c4前端
渣波1 小时前
React 性能优化实战:从 memo 到 Hooks 的底层原理与进阶封装
前端·javascript
触底反弹1 小时前
别再 Prop Drilling 了!一文彻底搞懂 React 组件通信的 5 种方案
前端·javascript·react.js
玉宇夕落1 小时前
受控与非受控组件以及一些表单的简单业务逻辑的理解
前端
两只羊ovo1 小时前
listToTree 速通:一维数组怎么变出多级菜单?Map 和 reduce 两种全解
前端·javascript