别让主线程加班:Web Worker 带你把重活丢到后台

0 阅读9分钟

点击一个按钮,页面却突然卡死;滚动没有反应,输入框也打不进字,过了几秒甚至弹出“页面无响应”。

问题可能只是这一段同步计算:

button.onclick = () => {
  let sum = 0;

  for (let i = 0; i < 500_000_000; i += 1) {
    sum += i;
  }

  console.log("算完了:", sum);
};

这不是 React 的问题,也不是 async/await 写得不够好,而是这段计算霸占了浏览器的 JavaScript 主线程。

本文会从事件循环讲到 Web Worker,再在 React 中实现一个“后台计算”示例。读完你应该能回答三个问题:

  • 为什么 Promise 不能解决 CPU 密集型计算?
  • Worker 和主线程之间如何传递任务和结果?
  • React 中应该在什么时候创建、使用和销毁 Worker?

一、JS 为什么是单线程?

JavaScript 最初主要服务于网页交互:点击、输入、表单和 DOM 更新都发生在页面环境中。

如果多个 JavaScript 线程可以同时修改同一个 DOM 节点,就会出现竞态:一个线程刚把按钮改成红色,另一个线程又改成蓝色,浏览器还要决定最终结果。

因此,一个页面中的 JavaScript 主线程一次只执行一个 JavaScript 任务。它既要处理用户事件,也要参与脚本执行和页面更新。

主线程
  ├── 处理点击和输入
  ├── 执行 JavaScript
  ├── 触发 React 更新
  └── 计算 5 亿次循环  ← 任务太重,其他事情只能排队

这里说的是 JavaScript 执行模型,不是说整个浏览器只有一条线程。浏览器本身是复杂的多进程、多线程软件,网络、渲染、GPU 和存储等工作并不都由同一个线程完成。

二、Event Loop 能解决什么,不能解决什么?

Event Loop 让 JavaScript 能够协调异步任务。例如发起网络请求后,主线程不用一直停在那里等待:

fetch("/api/data")
  .then((response) => response.json())
  .then((data) => console.log(data));

网络请求的等待阶段可以交给浏览器处理,结果回来后再把回调放回 JavaScript 任务队列。

但 CPU 密集型计算不同:

setTimeout(() => {
  // 这段计算最终仍然在主线程执行
  for (let i = 0; i < 500_000_000; i += 1) {
    // 耗时计算
  }
}, 0);

setTimeout 只能把任务推迟到未来执行,并没有创建新线程。时间到了之后,这段循环依然会占用主线程。

可以这样记:

问题Event Loop 是否擅长
等待网络响应擅长,等待期间主线程可以处理其他任务
等待定时器擅长,回调稍后再执行
大量同步计算不擅长,计算开始后仍会占用执行它的线程

image.png

三、Web Worker:把计算放到另一个 JavaScript 执行上下文

Web Worker 是浏览器提供的后台脚本能力。它拥有独立的 JavaScript 执行上下文和事件循环,可以在主线程之外执行计算。

它和主线程之间不是直接读写变量,而是通过消息通信:

主线程(UI)                         Worker 线程(计算)
    │                                      │
    │──── postMessage(任务) ─────────────→│
    │                                      │  执行计算
    │  继续响应点击和滚动                   │
    │←──── onmessage(结果) ────────────────│
    │                                      │
    │  setResult(结果)                      │

主线程和 Worker 可以并发执行,但是否真正同时占用不同 CPU 核心,仍由浏览器和操作系统调度。对前端来说最重要的收益是:重计算不再阻塞负责交互的主线程。

四、实战:在 React 中使用 Web Worker

下面用一个简单的求和任务演示完整流程。为了避免 JavaScript 精度问题,示例在循环中使用取模结果;实际项目应根据业务选择合适的数值类型和算法。

1. Worker 文件:接收任务并返回结果

// worker.js
self.onmessage = (event) => {
  const { num, iterations } = event.data;
  let result = 0;

  for (let i = 0; i < iterations; i += 1) {
    result = (result + (num * i) % 1_000_000_007) % 1_000_000_007;
  }

  self.postMessage({ result });
};

Worker 中没有页面 DOM,因此不能使用 windowdocument 或直接操作 React 组件。self 指向当前 Worker 的全局对象,onmessage 接收主线程发来的任务,postMessage 把结果发回主线程。

2. React 组件:准备 Worker 实例

Worker 是一个外部对象,不是页面要展示的状态。用 ref 保存它,用 state 保存会影响 UI 的数据:

import { useEffect, useRef, useState } from "react";

export default function WorkerDemo() {
  const workerRef = useRef(null);
  const [result, setResult] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const worker = new Worker(
      new URL("./worker.js", import.meta.url)
    );

    workerRef.current = worker;
    worker.onmessage = (event) => {
      setResult(event.data.result);
      setLoading(false);
    };

    return () => {
      worker.terminate();
      workerRef.current = null;
    };
  }, []);

创建 Worker、绑定监听和销毁线程都属于副作用,所以放在 useEffect 中。cleanup 使用 effect 内的局部 worker,比直接读取可变的 workerRef.current 更稳妥。

3. 点击按钮:发送任务并展示结果

下面的代码接在组件函数中:

  function startHeavyCalculation() {
    const worker = workerRef.current;
    if (!worker || loading) return;

    setLoading(true);
    worker.postMessage({
      num: 88,
      iterations: 50_000_000,
    });
  }

  return (
    <div>
      <button onClick={startHeavyCalculation} disabled={loading}>
        {loading ? "正在后台计算..." : "启动计算"}
      </button>
      {result !== null && <p>计算结果:{result}</p>}
    </div>
  );
}

result !== nullresult && 更准确:如果计算结果恰好是 0,页面也应该显示它。

不同构建工具的 Worker 引入方式可能略有差异。new URL("./worker.js", import.meta.url) 是 Vite 等现代工具常见的写法;如果使用其他脚手架,应以对应文档为准。

五、关键细节:为什么这样设计?

1. 为什么用 useRef 存 Worker?

useRef 在同一个组件实例的多次渲染之间保持稳定引用,修改 current 也不会触发渲染。Worker 本身不负责显示 UI,正好符合这个特征。

但不要把它理解成“只能用 ref”。useState 也可以保存一个对象引用,正确初始化时不会因为每次渲染就自动创建新 Worker。这里选 ref,是因为 Worker 是外部可变实例,不是需要驱动 UI 的状态,语义更准确。

2. 为什么必须清理 Worker?

组件卸载后,Worker 不会因为 React 组件消失就自动停止。如果不调用 terminate(),它可能继续占用 CPU,并继续向已经离开的页面发送消息。

return () => {
  worker.terminate();
  workerRef.current = null;
};

这和清理定时器、移除事件监听是同一类副作用管理问题。

3. 主线程和 Worker 如何通信?

主线程发送任务:

worker.postMessage({ num: 88, iterations: 50_000_000 });

Worker 返回结果:

self.postMessage({ result });

接收端通过 event.data 读取消息。普通对象会通过结构化克隆算法传递,接收端得到的是数据副本;对于 ArrayBuffer 等大块二进制数据,还可以使用 Transferable 转移所有权,减少复制成本。

六、Web Worker 能做什么,不能做什么?

能做不能直接做
纯计算、加密、数据转换操作页面 DOM
fetch 网络请求访问 windowdocument
console.log直接更新 React 组件
使用 postMessage 通信直接读取主线程变量
使用 IndexedDB 等 Worker 可用 API按普通页面脚本方式访问 localStorage

Worker 不是“后台版 React”,也不是共享内存线程。它更像一个只接收输入、产出结果的计算模块。

七、哪些场景适合使用 Worker?

常见场景包括:

  • 游戏中的碰撞检测、物理模拟
  • 浏览器端模型推理或向量计算
  • 加密、解密、压缩和解压
  • 大型 CSV、JSON 数据处理
  • 图片滤镜、压缩和格式转换

可以先用一个实用标准判断:如果某段同步任务经常让主线程卡顿,或者阻塞时间明显超过一帧渲染预算,就值得考虑 Worker。50ms 可以作为排查时的经验参考,但不是绝对阈值,最终还要结合设备性能和用户体验测量。

八、Worker 的成本也要考虑

Worker 不是免费加速器:

  • 创建线程有启动成本
  • 消息传递需要序列化或结构化克隆
  • 任务拆分和错误处理会增加代码复杂度
  • 主线程和 Worker 之间不能随意共享普通变量

很小的计算放进 Worker,可能因为通信成本反而更慢。先测量任务耗时,再决定是否迁移;对于大型二进制数据,优先了解 Transferable,避免无意义的数据复制。

九、常见误区

误区一:Promise 能把计算放到后台线程

Promise 只描述异步结果,不会自动创建线程。setTimeout、Promise 和 async/await 都不能改变同步计算最终在哪个线程执行。要把计算搬离主线程,需要 Web Worker 或其他真正提供线程能力的机制。

误区二:Worker 创建后不用清理

Worker 是外部资源。组件卸载时应该调用 terminate();如果注册了消息监听,也要解除监听或让 Worker 一并结束。

误区三:Worker 可以直接修改页面

Worker 没有页面 DOM。它只能把数据发回主线程,再由主线程通过 React state 更新界面。

十、面试怎么回答?

可以这样回答:

Web Worker 是浏览器提供的独立 JavaScript 执行上下文,适合处理 CPU 密集型任务。主线程通过 postMessage 发送数据,Worker 通过 onmessage 接收并计算,再用 postMessage 返回结果。Worker 不能直接访问 DOM 或 React 状态,因此主线程需要把结果接回 state。React 中通常在 useEffect 中创建和监听 Worker,用 useRef 保存实例,并在 cleanup 中调用 terminate 释放资源。

十一、总结

概念作用
JavaScript 主线程执行脚本、处理交互并参与 UI 更新
Event Loop协调异步等待和任务执行,不能搬走 CPU 计算
Web Worker在独立执行上下文中运行计算任务
postMessage主线程与 Worker 之间发送消息
onmessage接收另一端发来的消息
useRef保存 Worker 等外部实例的稳定引用
useEffect管理创建、监听和销毁时机
terminate停止 Worker,释放线程资源

最后记住一句话:JS 的主线程负责和用户打交道,Worker 负责处理可以搬走的重活。

Web Worker 没有改变 JavaScript 的单线程执行模型,它只是让浏览器给了我们一个独立的计算执行上下文。真正理解消息通信、生命周期和任务成本,才能把它用在合适的地方。