页面卡成PPT?用 useRef + Web Worker 一键搬走5亿次计算,主线程一根毛都不卡

0 阅读5分钟

开篇:你经历过"按钮点完没反应"的绝望吗?

先看一个真实场景:

你做了一个数据分析页面,用户点"生成报表"按钮,页面直接卡死8秒,鼠标转圈、点击无响应、用户以为你的代码炸了。

你以为是你写的循环太大?

image.png 不,你只是把一座山压在了主线程的背上。

今天这篇文章,我就用 1 个 Hook + 1 个 API ,把繁重计算从主线程"搬走",让你的 React 页面不管跑多重的计算都丝般顺滑。

读完你将掌握:

  • 为什么主线程会卡?
  • useRef 在 Worker 场景里的正确用法
  • Vite 项目里怎么正确引入 Worker 文件
  • 一套完整的"耗时计算 + Worker 通信 + 资源回收"实战方案

一、先从痛点开始:主线程为什么卡?

JavaScript 是单线程语言。

你写的 for 循环、mapfilter 统统跑在主线程上。

而这个主线程,它还负责:

  • DOM 渲染
  • 用户点击事件
  • 动画帧
  • 网络请求回调
  • React 的状态更新

当你在主线程跑一个 5 亿次循环:

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

这期间页面完全冻结——按钮点不动、输入框打不了字、滚动都像便秘。

主线程说:我在算数,别烦我。

这就是为什么用户点完按钮,过了8秒页面才有反应——主线程在这8秒里谁也顾不上。

二、一句话说清 Web Worker 是什么

Web Worker = 浏览器给你开的"独立子线程"。

主线程(React 页面)      Worker 线程(独立运行)
───────────────────    ────────────────────────
 负责渲染、交互             只负责计算
 不卡用户操作              干完活通知主线程

关键原则:主线程只管 UI,Worker 只管计算。

Worker 线程:

  • ✅ 可以干任何纯计算
  • ❌ 不能操作 DOM
  • ❌ 不能访问 window 对象
  • ✅ 只能通过 postMessage 和主线程通信

三、先写 Worker 文件:干活的人

创建 src/worker.js

// worker.js — 这是跑在独立子线程里的代码
console.log('worker 已上线,等待主线程派活...');

// self 是 Worker 线程里的全局对象(类似主线程的 window)
self.onmessage = (e) => {
  console.log(`worker 收到任务,参数:${JSON.stringify(e.data)}`);

  const { num } = e.data;
  let sum = 0;

  // 这里可以是任何重计算——数组处理、数据转换、正则匹配等等
  for (let i = 0; i < 500000000; i++) {
    sum += i * num;
  }

  // 干完了,把结果送回主线程
  self.postMessage({ result: sum });
};

划重点:

  • 入口是 self.onmessage——接收主线程发来的任务
  • postMessage——把结果送回主线程
  • 这个文件里的代码完全独立运行,不影响你的 React 页面

四、React 这边怎么接?用 useRef 拿住 Worker

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

const App = () => {
  const workerRef = useRef(null);    // 用来"拿住" Worker 实例
  const [result, setResult] = useState('');
  const [loading, setLoading] = useState(false);

  // 组件挂载时初始化 Worker(只创建一次)
  useEffect(() => {
    workerRef.current = new Worker(
      new URL('./worker.js', import.meta.url)  // Vite 专属写法
    );
  }, []);

  const startHeavyCalc = () => {
    setLoading(true);

    // 给 Worker 线程派活,带上参数
    workerRef.current.postMessage({ num: 88 });

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

      // 干完活就关闭,释放资源
      workerRef.current.terminate();
      workerRef.current = null;
    };
  };

  return (
    <div style={{ padding: '30px' }}>
      <h2>useRef + Web Worker 耗时运算</h2>
      <button onClick={startHeavyCalc} disabled={loading}>
        {loading ? '正在后台计算...' : '启动繁重计算任务'}
      </button>
      <p>计算结果:{result}</p>
    </div>
  );
};

export default App;

这段代码里 useRef 的作用特别关键:

const workerRef = useRef(null);

它不是用来存 DOM 的,而是存一个跨渲染周期不变的 Worker 实例引用

为什么不用 useState

方式问题
useState(new Worker(...))每次渲染都重新 new,Worker 会被反复创建销毁
useRef(new Worker(...))初始化一次,后面无论渲染多少次,拿到的都是同一个 Worker

useRef 的核心价值:存一个"跑在渲染之外的活物"。

五、Vite 的 Worker 引入坑:千万别直接传字符串

写过 Webpack 的同学可能习惯这样写:

// ❌ Vite 里这样写会翻车
new Worker('./worker.js');

Vite 的构建工具链不认识这种相对路径,你需要用特殊写法:

// ✅ Vite 正确写法
new Worker(new URL('./worker.js', import.meta.url));

import.meta.url 是 ESM 模块里的标准属性,表示当前文件的绝对 URL。new URL(...) 会把它和相对路径拼成 Worker 文件在打包后的真实地址。

如果你用的是 CRA(Create React App),直接用 new Worker('./worker.js') 就行,它内部做了 polyfill。

六、别忘了回收资源——terminate()

Worker 线程是系统资源,用完记得关:

workerRef.current.terminate();   // 关闭 Worker 线程
workerRef.current = null;        // 断开引用,让 GC 回收

不关闭会怎样?

  • Worker 线程继续占用内存
  • 浏览器标签页关掉也未必释放
  • 多开几个页面内存就飚上去了

良好习惯:每次计算结果回来就 terminate(),下次计算再 new

七、完整数据流:一次计算到底经历了什么

用户点击按钮
    ↓
主线程:setLoading(true),按钮变灰
    ↓
postMessage({ num: 88 }) → Worker 线程收到任务
    ↓
Worker: 5亿次循环计算中……(主线程在这期间完全自由)
    ↓
Worker 计算完成:postMessage({ result: xxx })
    ↓
主线程:onmessage 回调触发
    ↓
setResult(result) ,setLoading(false),按钮恢复
    ↓
terminate() → 工人都给你干完活了,下班!

在这整个过程中,你的页面按钮没卡、输入框能打字、抽屉能拉、导航能点——因为主线程只干了"发消息"和"收消息"两件轻活。

八、还有哪些场景该用 Worker?

场景举例
大数据计算5亿次循环、大数组处理
文件解析CSV 转 JSON、Excel 数据提取
加密/解密AES、RSA 前端加密
图片处理Canvas 滤镜、图像压缩
数据导出百万级数据的导出逻辑

一句话:凡是能让主线程"喘不上气"的纯计算,全扔 Worker。

九、总结

要点说明
useRef存 Worker 实例,跨渲染不丢失
new Worker(URL)Vite 下用 new URL('./worker.js', import.meta.url)
postMessage主线程 ↔ Worker 双向通信
onmessage接收对方的回信
terminate()干完活就关,别占着资源不拉屎
主线程只做 UI计算全交给 Worker,页面永远不卡

核心思想就一句:

把 CPU 密集型任务从主线程"搬走",还你的 React 页面一个丝滑体验。