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 的一条"干重活的胳膊"------活搬走了,手还是那只手(单线程),只是多了个帮手。

相关推荐
夏炳辉.10 分钟前
Flex布局中 flex: 1 的完整解析与实战指南
前端·css·css3
CIO_Alliance32 分钟前
AI提示系列(2)| Few-shot与ReAct有何不同? 大模型工具调用的底层逻辑详解
前端·人工智能·深度学习·神经网络·react.js·前端框架·ai+ipaas
cindershade1 小时前
别只收三个数字:前端 RUM 如何建立可解释的体验数据链
前端
前端 贾公子1 小时前
第09章:上下文与记忆 (4)
java·服务器·前端
Coodor1 小时前
使用web也可以写NFC微信小程序拉取
前端·微信小程序·小程序·nfc拉起小程序
Wang's Blog2 小时前
Vibe Coding一人即团队系列28: 前端、后端与数据库的概念解析
前端·数据库
Dovis(誓平步青云)2 小时前
模拟器横评:电脑上看小说、追短剧用什么模拟器?MuMu、雷电、腾讯手游助手实测
android·java·服务器·前端·javascript·电脑
踩蚂蚁2 小时前
把自定义唤醒词部署到 ESP32-S3:ONNX 转 INT8 TFLite 的完整链路
前端
前端繁华如梦2 小时前
用 Vite + Electron + React + Python 重造 3D 服装打版软件
前端