前端卡顿终结者:用 useRef 把 Web Worker 请进 React 项目

0 阅读10分钟

“单线程是 JS 的宿命,但不是你页面卡顿的借口。” —— 当你被主线程的复杂计算搞得焦头烂额时,Web Worker 就是你手里的那把手术刀。

最近在做 AI 相关的 Demo 时,遇到了一个典型场景:需要在浏览器端进行大量数据运算。直接用主线程跑,页面直接“卡死”——按钮点不动、滚动没反应、甚至连动画都掉帧到个位数。这便是 JS 单线程机制带来的典型痛点。

如果你也曾被“主线程阻塞”折磨过,本文将从原理到实战,带你一步步将 Web Worker 优雅地接入 React 组件,并用 useRef 管理其生命周期。读完你会发现:原来解决页面卡顿,可以这么简单。


1. JS 单线程,怎么就卡了?

先复习一下:JavaScript 在浏览器里是单线程运行的,它拥有一个 Event Loop 机制。

这个设计的本意很好:处理 DOM、响应用户交互、执行脚本……如果多个线程同时操作同一个 DOM,渲染一致性就会出问题。所以 JS 选择了“单线程 + 异步非阻塞”的模式,用 Event Loop 来调度任务。像网络请求、定时器这些 I/O 操作,交给浏览器其他线程处理,回来之后通过回调加入任务队列,主线程继续处理。

但是——  如果主线程上有一段极其耗时的纯计算任务呢?比如:

  • 游戏引擎的物理运算
  • LLM 模型的本地推理
  • 大量数据的加密、哈希计算
  • 5 亿次的数学循环(别笑,真有人这么干)

这些任务会直接霸占主线程,导致后续的用户交互、UI 更新、事件响应统统排队等待。页面自然就卡死了。你无法依赖异步来“绕过去”,因为它们本身就是 CPU 密集型,而不是 I/O 密集型。

正如笔记里写的:“JS 主线程负责脚本执行、DOM 渲染、用户交互,忙得飞起。” 繁重的计算只会让它雪上加霜。


2. Web Worker:浏览器给的“后台多线程”

为了解决这个问题,HTML5 提供了 Web Worker

说白了,Worker 就是浏览器给你开的一个独立后台线程,它拥有独立的全局上下文(没有 window 对象),拥有独立的内存空间。它不能直接访问 DOM,只能通过消息机制postMessage / onmessage)与主线程通信。

它的特性非常纯粹:

  • 并行执行:主线程和 Worker 线程各跑各的,互不阻塞。
  • 隔离安全:Worker 线程没有能力直接修改页面,这反而让事情变得更简单——它就是个“计算外包工”。
  • 消息序列化:传给 Worker 的数据会被结构化克隆(不是共享内存),避免了多线程并发问题。

所以,Worker 的适用场景也非常清晰:大计算量、纯算法、与 UI 无关的任务。比如在笔记中,我们用 Worker 完成了 5 亿次累加运算,对主线程来说是一场噩梦,但放入 Worker 后丝般顺滑。

但要注意,JS 并没有变成多线程语言。
JS 引擎(如 V8)本身仍维持单线程语义,Worker 是浏览器这个 C++ 多进程软件提供的辅助线程。主线程的渲染、事件、组件更新依然只能串行执行。Worker 的存在,只是让我们能把“苦力活”外包出去,而不是改变了 JS 的本质。


3. 把 Worker 接进 React:为什么是 useRef?

在 React 中引入 Worker,面临一个核心问题:组件每次重新渲染,都会重新执行函数组件。如果我们在组件体内 new Worker(...),每渲染一次就新建一个线程,那简直是灾难——内存泄漏、多个冗余线程、消息监听混乱。

我们需要一个能在组件整个生命周期内持久保存的对象,并且该对象的变化不会触发组件更新。这正好是 useRef 的拿手好戏。

  • useRef 返回一个可变的 ref 对象,其 .current 属性在整个生命周期内保持不变。
  • 更新 .current 不会引起重渲染(和 useState 的区别)。
  • 因此,它是存放 Worker 实例、定时器、DOM 引用等“幕后对象”的完美容器。

另外,Worker 的创建时机应该是在组件挂载后(useEffect),销毁时机则是在组件卸载时(useEffect 的清理函数)。同时,我们需要在 useEffect 中注册消息监听,同步 Worker 传递的结果到 React 状态中。

基本骨架如下:

const workerRef = useRef(null);

useEffect(() => {
  // 组件挂载后,初始化 Worker
  workerRef.current = new Worker(
    new URL('./worker.js', import.meta.url)
  );

  // 监听消息,更新 React 状态
  workerRef.current.onmessage = (e) => {
    // 处理数据...
  };

  // 清理:终止线程,释放内存
  return () => {
    workerRef.current?.terminate();
    workerRef.current = null;
  };
}, []);

这样一套组合拳,保证了 Worker 实例在整个组件生存期里只有一个,且不会因渲染而丢失。useRef 就像一个透明的保险箱,稳稳地兜底。


4. 完整实战:一个不卡页面的“重型计算”按钮

下面是一个可直接运行的例子。需求:点击按钮,启动 5 亿次的累加运算,计算期间页面仍可正常交互(滚动、点击等),结果返回后展示。

文件结构:

src/
  App.jsx
  worker.js

worker.js(独立子线程):

// 注意:Worker 中无法访问 DOM 及大部分浏览器 API
// self 为 Worker 的全局对象
self.onmessage = (e) => {
  console.log('Worker 收到主线程参数:', e.data);
  const { num } = e.data;
  let sum = 0;
  // 模拟耗时计算
  for (let i = 0; i < 500_000_000; i++) {
    sum += num * i;
  }
  // 将结果发回主线程
  self.postMessage({ result: sum });
};

App.jsx(主线程):

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

function App() {
  // 1. 用 useRef 持久化 Worker 实例
  const workerRef = useRef(null);
  const [result, setResult] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // 2. 组件挂载,创建 Worker
    workerRef.current = new Worker(
      new URL('./worker.js', import.meta.url)
    );

    // 3. 监听 Worker 返回的消息
    workerRef.current.onmessage = (e) => {
      const { result } = e.data;
      setResult(result);
      setLoading(false);
    };

    // 4. 组件卸载时,终止 Worker 并清理引用
    return () => {
      workerRef.current?.terminate();
      workerRef.current = null;
    };
  }, []);

  const startHeavyCalc = () => {
    setLoading(true);
    // 5. 向 Worker 发送计算指令
    workerRef.current?.postMessage({ num: 88 });
  };

  return (
    <div style={{ padding: 30 }}>
      <h2>useRef + Web Worker 耗时运算</h2>
      <p>
        点击按钮,Worker 会在后台执行 5 亿次循环;
        期间你可以随意滚动页面,验证主线程是否卡顿。
      </p>
      <button onClick={startHeavyCalc} disabled={loading}>
        {loading ? '后台计算中...' : '启动繁重计算任务'}
      </button>
      {result !== null && <h3>计算结果: {result}</h3>}
    </div>
  );
}

export default App;

运行一下,你会发现:点击按钮后,按钮变为“后台计算中...”,但页面滚动依然流畅,事件响应即时。计算完毕后,结果正常渲染到页面上。整个体验与之前的主线程阻塞形成了天壤之别。

💡 金句时刻useRef 存放的不是数据,是可信赖的“幕后队友”;Worker 不是让 JS 变多线程,只是请了个“外包小哥”。


5. 深入:线程间通信与内存回收

前面我们一直说“消息机制”,那这消息到底是怎么传的?数据在两条线程之间安全吗?内存又该如何正确回收?这一节我们一次性说透。

5.1 通信机制:结构化克隆

主线程和 Worker 之间的通信,通过 postMessage(data) 发送,onmessage 事件接收。数据传递的过程并不是“共享同一块内存”,而是浏览器内部执行了一次结构化克隆

结构化克隆会深度复制数据(包括对象、数组、Map、Set、ArrayBuffer 等),在目标线程中生成一份独立的副本。这样做的好处很明显:

  • 线程安全:两个线程各用各的数据,互不影响,不会出现多线程并发问题。
  • 隔离性强:Worker 无法意外修改主线程的数据,反过来也一样。

但缺点也同样存在:大数据的复制会消耗时间与内存。如果你只是传递一个包含几个数字的对象,这点开销可以忽略;但如果你要传一个几百兆的图片 Buffer,克隆一份会让内存瞬间翻倍,甚至引发卡顿。

5.2 Transferable 对象:零拷贝传递

好消息是,浏览器提供了Transferable 对象来实现“零拷贝”传输。原理是:将数据的“所有权”从一个线程转移到另一个线程,转移后原线程不再持有该数据,从而避免复制。

最常见的 Transferable 对象是 ArrayBuffer 和 MessagePort。用法是在 postMessage 的第二个参数中指定要转移所有权的对象数组:

// 主线程
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
workerRef.current.postMessage({ buf: buffer }, [buffer]);
// 此时主线程中的 buffer 已被转移,再访问会变为空(byteLength 变为 0)

// Worker 中
self.onmessage = (e) => {
  const receivedBuffer = e.data.buf; // 直接获得所有权,无复制
  // 使用 receivedBuffer...
};

你可以把这个过程想象成“户口迁移”:数据从主线程把“户口”迁到了 Worker,主线程就不再拥有它。这在处理大体积二进制数据(如音视频帧、大文件切片)时尤为高效,性能提升非常明显。

5.3 内存回收:别让 Worker 变成“幽灵线程”

Worker 线程一旦创建,就会一直在后台运行,直到被主动终止或页面关闭。如果只创建不回收,它就会成为内存泄漏的温床。正确的回收分两层:

1. 线程本身的终止

在 React 组件中,我们已经示范了在 useEffect 的清理函数中调用 workerRef.current.terminate()。这会立即终止 Worker 的执行,回收其占用的所有系统资源。如果你不调用 terminate,Worker 就会继续存活,即使所属组件已卸载,它可能还在后台空转或等待消息。

2. JavaScript 引用的解除

除了终止线程,还要手动将 workerRef.current 置为 null。这并不是必须的(因为 terminate 后 Worker 对象已经不可用),但有助于让垃圾回收器更早地识别这个对象已经不再被需要。在长时间运行的单页应用中,主动清理引用是一种良好的防御性编程习惯。

完整的生命周期流程

组件挂载 → new Worker() → 消息通信 → 组件卸载 → terminate() → ref清空 → 浏览器GC回收

记住:谁创建的 Worker,谁负责销毁。尤其是在 React 这种组件化的架构里,一定要把 Worker 的生命周期和组件的挂载/卸载紧密绑定。


6. 避坑提醒 & 最佳实践

  1. Worker 文件路径需符合打包环境
    使用 Vite 或 Webpack 时,new Worker(new URL('./worker.js', import.meta.url)) 可以保证文件被正确打包和代码分割。注意,有些脚手架(如 Create React App)可能需要额外的配置,优先查阅对应文档。
  2. 不要在 Worker 内部使用闭包变量
    Worker 的全局作用域是隔离的,无法读取主线程的变量。所有数据都必须通过消息传递,这实际上也是一种“纯度保护”。
  3. 大数据的传输考虑 Transferable 对象
    上文已详细说明:当传输 ArrayBuffer 等大体积二进制数据时,记得使用 Transferable 进行零拷贝转移,以避免克隆带来的内存和性能压力。
  4. 不要在 Worker 中干 UI 的活儿
    Worker 无 documentwindow 对象,任何试图操纵 DOM 的行为都会报错。保持它的纯粹:入数据,出结果。
  5. 及时 terminate
    组件卸载时一定要 workerRef.current.terminate(),否则 Worker 会常驻后台,造成内存泄漏。这一点上面已经反复强调,值得再念一遍:让 Worker 与组件同生共死

7. 总结:一张图理清主线程与 Worker 的协作

最后,让我们用一张简洁的流程图收尾:

[用户点击按钮] 
    → 主线程 postMessage({ num })
          ↓
    [Worker 线程] 执行 heavyCalc(不阻塞主线程)
          ↓
    [Worker] postMessage({ result })
          ↓
    [主线程 onmessage] → setResult → React 更新 UI

你会发现,主线程和 Worker 各司其职,整个通信过程清晰有序。在需要传输大量数据时,用 Transferable 还能进一步提升效率;用完后记得 terminate,让资源干干净净。

一句话总结:
useRef 让 Worker 实例与组件同生共死;Web Worker 让重计算与 UI 响应各安其道。两者的结合,正是 React 处理 CPU 密集型任务的最佳实践模板。

现在,你完全可以把这套模式复制到你的项目里——无论是跑个本地模型,还是处理大批量数据,都能让你的页面稳如磐石。