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

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

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


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

1.1 前端的本职工作

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

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

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

1.2 单线程的代价

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

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

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

二、Event Loop 能拯救吗?

2.1 异步 ≠ 多线程

javascript 复制代码
console.log('1');

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

console.log('3');

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

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

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

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

2.2 什么场景 Event Loop 搞不定?

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

vbnet 复制代码
需求变了:不只是"点击按钮弹个窗"
         而是"跑一个本地 LLM 模型做推理"
         "实时渲染一个 3D 游戏场景"
         "对 1GB 数据做加密处理"

→ Event Loop 异步调度不够用了
→ 需要真正的并行计算
→ Web Worker 来了

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

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

这是一个关键认知:

scss 复制代码
┌──────────────────────────────────┐
│        浏览器 (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

javascript 复制代码
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

ini 复制代码
// 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 完整数据流时间线

scss 复制代码
用户点击按钮
  │
  ▼
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?

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

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

5.2 为什么在 useEffect 里 new Worker?

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

sql 复制代码
时间线:
  ① 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:字符串序列化的消息机制

php 复制代码
// 主 → Worker
workerRef.current.postMessage({ num: 88 });

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

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

css 复制代码
主线程内存                 Worker 线程内存
  { num: 88 }  ──拷贝──►  { num: 88 }

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

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

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

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

为什么必须做这一步?

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

markdown 复制代码
组件卸载 → 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 节

八、总结

sql 复制代码
┌─────────────────────────────────────────────────────┐
│                    架构全景图                         │
│                                                     │
│   主线程                                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 这条"辅助通道",让前端真正具备了处理复杂计算的能力。

相关推荐
计科土狗2 小时前
GESP六级专题之类与对象
java·前端·数据库
anOnion3 小时前
构建无障碍组件之Listbox Pattern
前端·html·交互设计
凌涘3 小时前
前端路由(三):鉴权、拦截与重定向
前端
其美杰布-富贵-李3 小时前
第 8 篇:Three.js 材质系统
javascript·three.js·js
To_OC3 小时前
LC 3 无重复字符的最长子串:从入门滑动窗口到优化写法,再也不怕面试官追问
javascript·算法·leetcode
半个落月3 小时前
从零梳理 React Router:路由、懒加载、嵌套页面与登录鉴权
前端·react.js
tedcloud1234 小时前
Impeccable 部署指南:开源前端设计工具 Linux 环境搭建实践
linux·运维·服务器·前端·人工智能·开源
默_笙4 小时前
🚩 React + TypeScript 的 Props 通信,我从"把事件对象传给父组件"进化到了"只传值"
前端·javascript
渣波4 小时前
赋予 React “多线程”:Hooks + Web Worker 并发计算架构深度解析
前端·javascript
光影少年4 小时前
Codex 斜杠指令体系与业务场景调度实战
前端·react.js·reactivex