useRef + Web Worker 实战:React 如何优雅地拥抱多线程

30 阅读9分钟

useRef + Web Worker 实战:React 如何优雅地拥抱多线程

从 JS 单线程的瓶颈出发,逐层深入 Event Loop 的力不从心、Web Worker 的多线程之道、再到 useRef 在其中的桥接角色——用一份完整 Demo 打通 React 副作用管理、消息通信与资源清理的全链路。


一、JS 是单线程,为什么当初这么设计?

1.1 前端的本职工作

JavaScript 诞生之初就只做一件事:给网页加点交互——表单校验、点击弹窗、小动画。这些任务的特点是:

  • 任务轻、频率高
  • 必须和 DOM 紧密配合
  • 用户期望即时响应

如果 JS 是多线程的,两个线程同时改同一个 <div> 的内容怎么办?一个线程要删节点,另一个要改它的文字——这会产生竞态条件数据不一致。为了"显示和操作的一致性",单线程是最安全的默认选择

1.2 单线程的代价

// 这段代码会彻底卡死页面
for (let i = 0; i < 100000000; i++) {
  console.log(i);
}

执行 1 亿次循环期间,浏览器完全无法响应任何用户操作——点击无效、滚动卡死、动画掉帧。因为主线程被这段同步代码霸占了

主线程时间线:
┌──────────────────────────────────────────────────────┐
│  [1亿次循环...CPU 100%]                               │
│  ↓ 用户点击按钮(排队等待)                             │
│  ↓ 用户滚动页面(排队等待)                             │
│  ↓ ...                                               │
│  ↓ 循环结束                                           │
│  → 处理排队的事件                                     │
│  → 用户感觉"卡了"                                     │
└──────────────────────────────────────────────────────┘

二、Event Loop 能拯救吗?

2.1 异步 ≠ 多线程

console.log('1');

setTimeout(() => {
  console.log('2');     // 异步,放到宏任务队列
}, 0);

console.log('3');

// 输出顺序:1 → 3 → 2

Event Loop 的机制是不阻塞——把耗时操作挂起来,先去干别的事。但关键认知是:

Event Loop 只是同一个线程上的"任务调度策略",它没有开辟新线程。

主线程唯一的执行栈:
  ┌──────────────────────────────────────────────┐
  │ 同步代码 → 微任务队列(Promise) → 宏任务队列(setTimeout) │
  │ 所有队列里的任务最终都回到同一条主线程上执行            │
  │ 该卡还是卡——只是"晚点卡"                           │
  └──────────────────────────────────────────────┘

2.2 什么场景 Event Loop 搞不定?

当一个任务是计算密集型的——LLM 推理、游戏物理引擎、大量加密解密——即使把它拆成多个异步块,每个块的执行仍然占用主线程的那条单行道。页面就会间歇性卡顿。

需求变了:不只是"点击按钮弹个窗"
         而是"跑一个本地 LLM 模型做推理"
         "实时渲染一个 3D 游戏场景"
         "对 1GB 数据做加密处理"Event Loop 异步调度不够用了
→ 需要真正的并行计算
→ Web Worker 来了

三、Web Worker:浏览器的多线程方案

3.1 JS 单线程并没有改变——但浏览器是多线程的

这是一个关键认知:

┌──────────────────────────────────┐
│        浏览器 (C++ 程序)          │
│                                  │
│  ┌──────────┐  ┌──────────┐     │
│  │ JS 主线程  │  │ Worker线程 │     │
│  │ (V8 引擎)  │  │ (独立 V8)  │     │
│  │           │  │           │     │
│  │ DOM 操作 ✓ │  │ DOM 操作 ✗ │     │
│  │ UI 渲染 ✓  │  │ 纯计算 ✓   │     │
│  └──────────┘  └──────────┘     │
│       │              │           │
│       └── postMessage ─┘          │
│          消息通信                  │
└──────────────────────────────────┘

浏览器本身是 C++ 写的多进程多线程软件。Web Worker 是浏览器提供的一个新的 JS 运行时环境——独立的 V8 引擎实例、独立的内存堆、独立的事件循环。和主线程物理隔离,互不干扰。

所以:

JS 单线程机制没有变——你写 JS 代码时仍然是单线程思维。只是在需要的时候,浏览器另开一个"单线程 JS 环境"来帮你并行干活。

3.2 Worker 的硬限制

  • ❌ 不能访问 DOM——Worker 里没有 document、没有 window
  • ❌ 不能直接操作页面——UI 更新只能通过消息通知主线程来做
  • ✅ 有自己的 API 子集——selfpostMessageonmessagefetchMath 等

这恰好符合 React 的哲学——React 本来就帮我们操作 DOM,Worker 专注纯计算就好。


四、完整 Demo 拆解

4.1 主线程:App.jsx

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

function App() {
  console.log('main thread');
  // 为组件的渲染挂载让路 —— 优先渲染 UI,Worker 等等再开
  const workerRef = useRef(null);         // 持久持有 Worker 实例
  const [result, setResult] = useState(null);   // 响应式:计算结果
  const [loading, setLoading] = useState(false); // 响应式:加载状态

  useEffect(() => {
    // ① 组件挂载后:创建 Worker(开销大,放 effect 里)
    workerRef.current = new Worker(
      new URL("./worker.js", import.meta.url)
    );

    // ② 监听 Worker 的消息
    workerRef.current.onmessage = (e) => {
      console.log(e);
      const { result } = e.data;
      setResult(result);       // 计算结果写入状态 → 触发 UI 更新
      setLoading(false);       // 关掉 loading 态
    };

    // ③ 组件卸载时:清理 Worker
    return () => {
      workerRef.current.terminate();  // 立即终止线程
      workerRef.current = null;       // 手动回收引用
    };
  }, []);

  const startHeavyCalc = () => {
    setLoading(true);
    // ④ 消息机制:给 Worker 发送任务指令
    workerRef.current.postMessage({
      num: 88
    });
  };

  return (
    <div style={{ padding: "30px" }}>
      <h2>useRef + WebWorker 耗时运算</h2>
      <p>开启 web worker 线程执行 50 亿次循环,结束后通知主线程</p>
      <button
        onClick={startHeavyCalc}
        disabled={loading}
      >
        {loading ? "正在后台计算..." : "启动繁重计算任务"}
      </button>
      {result && <h3>计算结果:{result}</h3>}
    </div>
  );
}

4.2 Worker 线程:worker.js

// Web Worker 独立子线程 —— 不可以做 DOM API,有自己的 API 子集
// self 关键字指向 Worker 自身的全局作用域

self.onmessage = (e) => {
  const { num } = e.data;
  console.log('Worker 收到主线程任务,参数为:', e.data);

  let sum = 0;
  for (let i = 0; i < 5000000000; i++) {   // 50 亿次循环!
    sum += num * i;
  }

  // 计算完成,把结果发回主线程
  self.postMessage({
    result: sum
  });
};

4.3 完整数据流时间线

用户点击按钮
  │
  ▼
startHeavyCalc()
  ├── setLoading(true)          → UI 立即显示"正在计算..."
  └── workerRef.current.postMessage({ num: 88 })
        │
        ▼  (消息跨越线程边界)
  Worker 线程收到 onmessage
        │
        ▼
  执行 50 亿次循环
  (CPU 100%,但不在主线程!
   主线程此刻仍然可以响应用户点击、滚动!)
        │
        ▼
  self.postMessage({ result: xxx })
        │
        ▼  (消息跨越线程边界)
  主线程 workerRef.current.onmessage 触发
        │
        ▼
  setResult(result)   → UI 更新,显示结果
  setLoading(false)   → 按钮恢复可点击

核心体验:50 亿次循环在跑的时候,页面按钮、滚动、输入框一切正常——因为计算发生在另一个线程的另一个 V8 实例里,主线程的事件循环完全不受干扰。


五、逐行解读关键设计

5.1 为什么用 useRef 而不是 useState 存 Worker?

const workerRef = useRef(null);
对比useRefuseState
存值workerRef.current = ...setWorker(...)
触发渲染❌ 否✅ 是
组件重渲染后值仍在值仍在
是否需要触发渲染Worker 实例变了≠界面要刷新

Worker 实例只是"后台干活的家伙",它从 null 变成 Worker 对象、发消息、收消息——这些操作都不需要驱动 UI 更新。真正需要驱动 UI 的是 resultloading,它们用 useState。这就是"各司其职"。

5.2 为什么在 useEffect 里 new Worker?

注释"为组件的渲染挂载让路"的含义:

时间线:
  ① render 阶段:App() 执行 → 返回 JSX → 生成 DOM
     (此时 workerRef.current 仍然是 null)
  
  ② commit 阶段:DOM 挂载到页面 → 用户看到界面
  
  ③ effect 阶段:useEffect 回调执行 → new Worker → workerRef.current 指向 Worker
     (用户已经看到界面了,Worker 的创建不会阻塞首次渲染)

如果把 new Worker() 写在组件函数体中(render 阶段),首次渲染会等 Worker 创建完才显示界面——虽然 Worker 创建很快,但这是架构层面的好习惯:渲染优先,副作用靠后。

5.3 postMessage:字符串序列化的消息机制

// 主 → Worker
workerRef.current.postMessage({ num: 88 });

// Worker → 主
self.postMessage({ result: sum });

底层机制:postMessage 内部会做结构化克隆(Structured Clone Algorithm)——它不传引用,而是把数据完整拷贝一份传给对方线程。

主线程内存                 Worker 线程内存
  { num: 88 }  ──拷贝──►  { num: 88 }

这就是"两个线程隔离开"的体现——它们不共享任何内存,通信全靠消息拷贝。这也意味着:

  • 传大对象有拷贝开销,建议传关键数据而非整份数据集
  • 不能传函数、不能传 DOM 节点

5.4 清理函数:谁创建,谁销毁

return () => {
  workerRef.current.terminate();  // ① 立即终止 Worker 线程
  workerRef.current = null;       // ② 清空引用,帮助 GC
};
  • terminate():浏览器 API,立即杀死 Worker 线程并释放独立内存。不管 Worker 在做什么——循环、等待、计算——直接终止。
  • = null:语义上标记"这个 Worker 已经死了",并帮助 V8 垃圾回收。

为什么必须做这一步?

如果不清理,组件卸载后 Worker 仍然活着:

组件卸载 → Fiber 回收 → JS 引用丢失
                    → Worker 线程仍在运行 💀
                    → 内存泄漏
                    → Worker 完成后 postMessage → 无人接收 → 错误

这就是 React 副作用管理的核心范式:对称性——Setup 里创建的资源,Cleanup 里必须销毁。


六、Web Worker 适合什么场景?

根据 readme 笔记的总结:

适合不适合
游戏引擎物理计算DOM 操作(Worker 里没有 DOM API)
本地 LLM 模型推理需要直接改界面的任务
加解密等密集计算轻量级异步请求(fetch 就够了)
大数据处理 / 排序简单的状态管理
图片 / 音视频处理依赖 window 对象的逻辑

一句话判断:这个任务是纯计算(不碰 DOM),且耗时超过 50ms(一帧的预算)吗?是 → 考虑 Worker。


七、readme 笔记完整对应表

笔记要点文中位置
JS 单线程、Event Loop 机制第一章、第二章
复杂任务 Event Loop 搞不定第二章第 2 节
Web Worker 线程 — 浏览器提供的独立线程第三章
Worker 无法访问 DOM,用消息机制通信第三章第 2 节 + 第五章第 3 节
实例化 Worker:new Worker(new URL(...))第四章第 1 节
消息机制:postMessage / onmessage第四章第 1 节 + 第五章第 3 节
JS 单线程没有变,浏览器是多线程的第三章第 1 节
主线程和 Worker 隔离,互不干扰第三章第 1 节 + 第五章第 3 节
useRef 持久存放 Worker 实例第五章第 1 节
useEffect 挂载后初始化,优先渲染第五章第 2 节
监听 + 发送数据第四章第 1 节 + 第五章第 3 节
组件卸载时销毁线程(terminate + = null)第五章第 4 节
JS 仍是单线程语言第三章第 1 节

八、总结

┌─────────────────────────────────────────────────────┐
│                    架构全景图                         │
│                                                     │
│   主线程                                Worker 线程  │
│   ┌──────────────┐                     ┌──────────┐ │
│   │ React 组件树  │                     │ 纯计算逻辑 │ │
│   │              │    postMessage      │          │ │
│   │ useState ────┼──── 驱动 UI  ──────►│ 密集循环  │ │
│   │   ↑          │                     │ LLM 推理  │ │
│   │   │ 数据绑定  │  ◄─── onmessage ───│ 加解密    │ │
│   │   │          │     结果返回         │ 游戏引擎  │ │
│   │ useRef ──────┼── 持有引用,不发渲染  │          │ │
│   │              │                     └──────────┘ │
│   │ useEffect ───┼── 创建/销毁 Worker               │
│   └──────────────┘                                   │
│                                                     │
│   核心原则:                                         │
│   · 响应式的归 useState(result、loading)            │
│   · 非响应式的归 useRef(Worker 实例)               │
│   · 创建和销毁归 useEffect(对称性)                  │
│   · 纯计算归 Worker(不碰 DOM)                      │
│   · 通信靠 postMessage(结构化克隆)                  │
└─────────────────────────────────────────────────────┘

一句话收束useRef 持有一个不触发渲染的 Worker 引用,useEffect 管生管死,主线程通过 postMessage 发任务、通过 onmessage 收结果——整个过程中页面始终流畅。JS 仍然是单线程语言,但浏览器通过 Web Worker 这条"辅助通道",让前端真正具备了处理复杂计算的能力。