别只拿 useRef 绑 DOM 了,搭配 Web Worker 解决页面卡顿才是真的香

昨天写一个前端文件加密的功能,一跑起来页面直接卡死半分钟,按钮点不动,滚动都掉帧,用户体验烂到离谱。我当时第一反应是算法写太烂了,赶紧把循环优化了一遍,变量能提外面就提外面,甚至换了种写法,结果收效甚微,该卡还是卡。

后来邻座的同事瞟了一眼我的代码,说:"你把几亿次循环扔主线程跑,不卡才怪,用 Web Worker 啊。"

我这才反应过来,自己一直知道 JS 是单线程,但真遇到场景的时候,第一反应居然还是去抠循环的细节,完全没想到可以把计算挪到后台线程去。借着这次踩坑,我顺便把 useRef 的本质也彻底捋明白了 ------ 原来这玩意儿根本不是只能用来拿 DOM。

为什么一段循环就能把页面卡崩?

说实话,我之前对 "JS 单线程" 的理解一直停留在面试题层面,背过 event loop,知道宏任务微任务,但从来没真的体感过 "阻塞主线程" 到底是啥感觉。

这次算是实打实地感受到了。我们常说的 JS 单线程,指的是浏览器里负责执行 JS 代码、处理 DOM 渲染、响应用户交互的那个主线程只有一个。你可以把它想象成一个只有一个窗口的办事大厅,所有的事情都得排队办:你点一下按钮要办,页面要重渲染要办,你的循环计算也要办。

如果你的循环计算量特别大,比如上亿次的累加、加密解密、大数据处理,那这个办事窗口就被你占死了。后面的点击事件、页面滚动、重绘重排,全都得排在后面等着,表现出来就是页面卡死,点啥都没反应。

我之前总觉得,用 Promise、用异步就能解决所有卡顿问题。后来才发现根本不是一回事:异步只是把任务排到后面去执行,真要是一段同步的重型计算跑起来,它占着主线程不放,啥任务都得等着。异步解决的是 "先后顺序" 的问题,解决不了 "占着资源不放" 的问题。

那怎么办?总不能前端就做不了重计算了吧。当然不是,浏览器早就给我们准备了后手 ------Web Worker。

useRef 到底是个啥?我之前一直用错了

在讲 Web Worker 之前,我得先把 useRef 讲明白,因为这次踩坑最大的收获,就是彻底搞懂了 useRef 的本质。

很长一段时间里,我对 useRef 的认知就停留在 "绑定 DOM 节点" 上。比如要让输入框自动聚焦,我就会这么写:

javascript 复制代码
import { useRef, useEffect, useState } from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  const inputRef = useRef(null);

  console.log('----------------');
  console.log(inputRef.current); // 注意这行,第一次打印是 null

  useEffect(() => {
    console.log(inputRef.current); // 这里才能拿到真正的 DOM
    inputRef.current.focus();
  }, [])

  return (
    <>
      <input 
        type='text' 
        placeholder='请输入用户名' 
        ref={inputRef}
      />
      {count}
      <button onClick={() => setCount(count + 1)}>增加</button>
    </>
  )
}

export default App;

我刚学的时候在这踩了个小坑:刚声明完 inputRef 就去读 current,结果打印出来是 null,我当时懵了,以为自己写错了 ref 属性。后来才反应过来,代码执行到 console.log 的时候,return 里的 JSX 还没渲染成真实 DOM 呢,React 还没把 DOM 节点挂到 ref 的 current 上,当然是 null。

真正能拿到 DOM 节点的时机,是在 useEffect 里 ------ 这个时候组件已经挂载完成,DOM 都渲染好了,React 已经把引用赋值给 current 了。

这还不是最有意思的。你点一下 "增加" 按钮,触发组件重渲染,再看控制台:

第二次、第三次渲染的时候,函数体里的 console.log(inputRef.current) 已经能打出完整的 DOM 节点了,不再是 null。

这就是 useRef 最核心的特性:它会在组件的多次渲染之间,始终返回同一个对象引用。简单说就是,组件第一次渲染的时候创建了这个 ref 对象,后面不管重渲染多少次,useRef 给你的都是同一个对象,不会重新创建。所以你第一次在 useEffect 里给 current 赋了 DOM 节点,后面渲染的时候,这个值就一直留在那儿了。

我当时看到这个输出的时候突然就开窍了:合着 useRef 根本不是专门给 DOM 准备的,它就是一个 "盒子",一个在组件整个生命周期里都不会消失的盒子。你往里面放 DOM 也行,放数字也行,放对象也行,放啥都行。

那它和 useState 有啥区别?区别就在于,你改 useState 的值,组件会重新渲染;你改 ref.current 的值,组件完全没反应,不会触发重渲染。

比如你要存一个定时器的 ID,或者存一下上一次的 props 值,这些东西不需要驱动页面更新,你要是存在 useState 里,平白无故触发渲染,纯纯浪费性能。扔 useRef 里就正好,改了就改了,安安静静的。

我写了个小 demo 验证这件事:

javascript 复制代码
    const App = () => {
      const numRef = useRef(0);
      const [, forceRender] = useState(0);

      console.log(numRef.current);

      return (
        <>
          <div onClick={() => {
            numRef.current += 1; // 只改 ref,不会触发渲染
            forceRender(Math.random()); // 强制刷新,才能看到页面上的数字变化
          }}>
            {numRef.current}
          </div>
        </>
      )
    }

你要是把 forceRender 那行去掉,光点 div,页面上的数字永远不会变,因为组件根本不重渲染。但 numRef.current 的值其实已经加上去了,只是视图没更新而已。

小总结useState 管的是 "响应式状态",改了就要更新视图;useRef 管的是 "非响应式引用",改了就改了,和视图没关系。

想明白这点之后,我再看 Web Worker 的用法,一下就知道该怎么写了。

进阶玩法:用 useRef 托管 Web Worker 实例

Web Worker 说白了就是浏览器给 JS 开的一个后台线程,专门用来跑纯计算任务,不影响主线程的渲染和交互。主线程和 Worker 线程之间靠发消息通信,互不干扰。

但在 React 组件里用 Worker,有个很现实的问题:Worker 实例创建一次就够了,总不能组件每次重渲染都新开一个线程吧?那内存不得炸了。

那这个实例存在哪儿?

  • 存在组件外面?那多组件实例的时候会共享同一个 Worker,容易出问题。
  • 存在 useState 里?完全没必要,又不需要用它来驱动渲染,set 一下还白白触发重渲染。
  • 最合适的地方,就是 useRef。

正好符合 useRef 的特点:跨渲染保持引用,修改不触发重渲染,完美适配 Worker 实例的存放需求。

我写了个完整的 demo,你可以直接跑:

主线程 App.jsx

javascript 复制代码
    import { useRef, useEffect, useState } from 'react';

    function App() {
      // 用 ref 存 worker 实例,跨渲染不重置
      const workerRef = useRef(null);
      const [result, setResult] = useState(null);
      const [loading, setLoading] = useState(false);

      useEffect(() => {
        // 组件挂载后再初始化 Worker,不阻塞首屏渲染
        workerRef.current = new Worker(
          new URL('./worker.js', import.meta.url)
        );

        // 监听 Worker 发回来的计算结果
        workerRef.current.onmessage = (e) => {
          const { result } = e.data;
          setResult(result);
          setLoading(false);
        }

        // 组件卸载时销毁 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 线程执行 5 亿次循环,结束后通知主线程</p>
          <button 
            onClick={startHeavyCalc}
            disabled={loading}
          >
            {loading ? '正在后台计算...' : '启动繁重计算任务'}
          </button>
          {result && <h3>计算结果:{result}</h3>}
        </div>
      )
    }

    export default App;

然后是 Worker 线程的 worker.js

javascript 复制代码
    console.log('worker 线程开启');

    self.onmessage = (e) => {
        const { num } = e.data;
        console.log('Worker 收到主线程任务,参数为:', e.data);
        
        let sum = 0;
        // 模拟重型 CPU 计算
        for (let i = 0; i < 5000000000; i++) {
            sum += num * i;
        }

        // 算完了给主线程发消息
        self.postMessage({
            result: sum
        })
    }

这么写的好处很明显:

  1. Worker 只在组件挂载的时候初始化一次,后面重渲染都复用同一个实例;
  2. 实例存在 ref 里,组件内任何地方都能通过 workerRef.current 拿到,发消息、监听都方便;
  3. 组件卸载的时候顺手把线程销毁,不会留下内存泄漏的隐患。

注意Web Worker 是完全独立的线程,不能访问 DOM,不能用 windowdocument 这些对象,只能做纯计算。所有和页面交互、更新视图的逻辑,都必须通过 postMessage 传回主线程来做。

很多人会问,那 JS 这不就变成多线程语言了吗?其实不是。JS 本身的单线程机制没变,Worker 线程是浏览器提供的,是浏览器这个软件开的线程,不是 JS 语言本身的能力。而且 Worker 和主线程是完全隔离的,各跑各的,不会共享内存,也就不会有多线程常见的资源竞争问题。

说白了,JS 还是那个单线程的 JS,只是浏览器给它找了个帮手,脏活累活让帮手去干,干完了喊一声就行。

踩过的几个坑,别再往里跳了

这次折腾下来踩了不少坑,挑几个典型的说说,省得你们再走弯路。

第一个坑,也是最入门的坑:刚声明完 ref 就去拿 DOM,拿到 null。这个前面说过了,记住 DOM 挂载是在渲染之后,要拿就去 useEffect 里拿。

第二个坑,Worker 路径写错。我最开始直接写 new Worker('./worker.js'),结果一直 404。后来才知道在 Vite 这类构建工具里,得用 new URL('./worker.js', import.meta.url) 这种写法,才能正确解析路径。别问我为什么知道,试了三次才试对。

第三个坑,忘了销毁 Worker。一开始我只写了初始化,没写清理函数。结果页面切来切去,控制台里多了好几个 Worker 线程,内存蹭蹭涨。组件卸载的时候一定要调用 terminate() 把线程关掉,这是个好习惯。

第四个坑,什么东西都往 Worker 里塞。Worker 不是银弹,创建线程本身是有开销的。就加个几百次的计算,完全没必要开 Worker,反而更慢。只有遇到真正的 CPU 密集型任务,比如大数据处理、加密、游戏物理计算、跑轻量模型这种,再考虑用 Worker。

还有一个坑,我一开始傻乎乎把 Worker 实例存在 useState 里,结果初始化完还要 set 一下,平白无故多触发一次渲染。现在想想,完全没必要,不需要驱动视图的东西,一律优先考虑 ref。

最后说两句

折腾完这一圈,我最大的感受就是,很多 API 你天天用,但未必真的懂它的本质。就像 useRef,我用了快两年,一直以为它就是个绑 DOM 的工具,直到这次场景逼到份上了,才真正想明白它 "持久化可变引用" 的核心。

回头捋一下,其实就三点:

  1. useRef 本质是个跨渲染的 "盒子",啥都能装,不止是 DOM;
  2. 改 ref 不会触发重渲染,适合存不需要驱动视图的值;
  3. Web Worker 是浏览器给的后台线程,搭配 useRef 存放实例最合适,专门解决主线程 CPU 阻塞的问题。

当然了,技术从来没有银弹。不要为了用而用,简单的交互逻辑就老老实实写在主线程,真遇到页面卡得动不了的计算场景,再把 Worker 掏出来。

反正我搞懂这俩玩意儿之后,之前那个加密页面终于丝滑起来了,点按钮、滚动都不卡了。如果你也遇到过类似的主线程阻塞问题,或者总搞不清 useRef 除了绑 DOM 还能干啥,不妨自己写个 demo 跑一跑。跑通了记得回来留个言,我也想看看你平时都用 useRef 存些啥奇奇怪怪的东西。

相关推荐
IT_陈寒2 小时前
Vue的响应式让我原地破防,原来问题出在这
前端·人工智能·后端
子兮曰2 小时前
Bun vs Node.js 深度对决:跑分快 4 倍,真实业务只剩 3%,2026 年到底该怎么选?
前端·后端·typescript
计算机魔术师3 小时前
终端用户
前端
码事漫谈3 小时前
把 AI 拆掉,你的系统还能跑吗?
前端·后端
小当家.1053 小时前
深入理解 ReAct Agent:从原理到 Java 实战
java·react.js·agent·react·架构设计·agent设计
默_笙4 小时前
⛵ 我用 React + TS 做了个"调色盘",顺便学会了企业级项目的目录架构
前端·javascript
预知同行4 小时前
从 MCP 到 CLI:AI Agent 工具链的架构演进与实战抉择
前端·面试
何智超4 小时前
AI 微前端性能优化之旅(中):重启
前端·vibecoding
渣波4 小时前
拒绝 Redux 样板代码:Zustand 核心原理与实战进阶指南(基础、异步与切片模式)
前端·javascript