从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程

从 useRef 到 Web Worker:理解 React 可变对象与浏览器多线程

本文根据个人学习笔记整理,结合两个小 demo,梳理 useRef 的核心特性,并延伸到 JS 单线程、event loop 与 Web Worker 的关系。适合正在学习 React Hooks、想搞懂 useRef 用法和浏览器多线程机制的同学。

一、先温习:useState 与 useEffect

React 函数组件引入 Hooks 后,最常用的两个:

  • useState:创建响应式状态。状态变化,组件重新渲染。
  • useEffect:处理副作用。在渲染完成后执行,比如操作 DOM、订阅、请求数据等。
jsx 复制代码
const [count, setCount] = useState(0)

useEffect(() => {
  document.title = `你点击了 ${count} 次`
}, [count])

这是 React 声明式编程的基础:你只描述"状态应该是什么",React 负责更新界面

二、useRef:持久可变的"容器"

官方一句话:useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数,并且在整个组件生命周期中保持不变

它有两个重要身份:

  1. 引用 DOM 节点:拿到真实 DOM 对象。
  2. 保存任意可变值:而且改变它不会触发组件渲染。

理解这个"不会触发渲染"非常关键,我们后面会反复提到。

三、为什么 React 不直接操作 DOM?

在 React/Vue 出现之前,前端大量使用原生 JS 操作 DOM:

js 复制代码
document.getElementById('app').innerHTML = '<span>你好</span>'

这种命令式写法虽然直观,但非常消耗性能。原因有两点:

  • JS 运行在 V8 引擎,DOM 在渲染引擎,两者跨线程通信成本高。
  • DOM 变化会触发重排(reflow)和重绘(repaint),频繁操作会卡顿。

React 和 Vue 的核心改进之一就是:规避直接 DOM 编程,由框架帮你高效更新 DOM。你只需要管理数据状态,例如:

jsx 复制代码
const [message, setMessage] = useState('你好')
return <span>{message}</span>

message 改变,React 通过虚拟 DOM diff 找到最小变更,再去操作真实 DOM。

四、如果非要操作 DOM,useRef 来了

虽然 React 不推荐直接操作 DOM,但有些场景确实需要:

  • 自动聚焦输入框
  • 滚动到某个位置
  • 获取元素尺寸、画布上下文
  • 集成第三方非 React 库

那么,在 React 中如何拿到这些真实的 DOM 节点呢?答案就是前面提到的 useRef 。它允许我们创建一个 ref 对象,并通过 JSX 的 ref 属性绑定到元素上,从而在组件中访问到真实的 DOM 节点。这正是 React 为我们保留的一个"受控出口"。

有了这个出口,我们才能实现上面那些场景。但更重要的是,这些操作并非单纯的技术需求,它们背后都有明确的用户体验目标。正如我学习笔记中的那句代码注释所说:"把用户当小白,前端的职责就是打造良好的用户体验"

以自动聚焦为例:用户打开一个登录页,如果光标已经自动停在用户名输入框里,他就可以直接打字,省去"手动点击输入框"这一步。对于追求效率的用户来说,这一个小小的细节,就能让体验更顺畅。滚动到某个位置,可能是为了引导用户注意力,比如进入聊天页面自动滚到最新消息;获取元素尺寸,可能是为了实现更精准的响应式布局或动画。

所以,useRef 提供的这个"受控出口",不仅是技术上操作 DOM 的通道,更是前端工程师优化用户体验的入口。我们不是为了操作 DOM 而操作 DOM,而是为了服务用户。

示例:页面加载后自动聚焦输入框:

jsx 复制代码
import { useRef, useEffect } from 'react'

function AutoFocusInput() {
  const inputRef = useRef(null)

  useEffect(() => {
    console.log(inputRef.current) // <input ...>
    inputRef.current.focus() // 挂载后自动聚焦
  }, [])

  return (
    <input
      type="text"
      placeholder="请输入用户名"
      ref={inputRef}
    />
  )
}

执行流程:

  1. useRef(null) 创建一个 ref 对象,current 初始为 null
  2. JSX 中 <input ref={inputRef} /> 把 DOM 节点绑定到 inputRef.current
  3. 组件渲染完成,useEffect 执行,此时 inputRef.current 已经指向真实 input。
  4. 调用 .focus(),用户无需点击即可输入。

这里 useEffect 空依赖数组表示只在挂载后执行一次。

五、useRef 的另一个身份:不触发渲染的可变值

除了 DOM,useRef 还能保存任意值。看一个例子:

jsx 复制代码
function NonReactiveCounter() {
  const numRef = useRef(0)

  return (
    <div onClick={() => numRef.current++}>
      {numRef.current}
    </div>
  )
}

点击后,numRef.current 确实在增加(你可以在 React DevTools 中看到),但页面显示的始终是 0。因为 useRef 的值变化不会触发组件重新渲染

如果我们希望界面跟着变,必须借助 useState 触发一次渲染:

jsx 复制代码
function ReactiveCounter() {
  const numRef = useRef(0)
  const [, forceRender] = useState(0) // 只用来强制刷新

  return (
    <div onClick={() => {
      numRef.current++
      forceRender(x => x + 1) // 手动触发渲染
    }}>
      {numRef.current}
    </div>
  )
}

这个对比非常直观:

特性 useState useRef
是否触发渲染 ✅ 是 ❌ 否
用途 聚焦数据状态、业务状态 DOM 引用、持久化可变值
常见例子 计数、表单、列表 定时器 id、worker 实例、DOM 节点

何时用 useRef 保存值?

当你需要跨渲染保存一个变量,但它的变化又不需要立刻反映到 UI 上时,用 useRef 很合适。例如保存定时器 ID、动画帧 ID、WebSocket 连接、Web Worker 实例等。

六、JS 单线程与 event loop:为什么前端怕阻塞

要理解 Web Worker,得先理解 JS 单线程。

浏览器中,JS 主线程负责

  • 执行 JS 代码
  • 处理用户交互
  • 更新渲染页面

如果 JS 是多线程,多个线程同时操作 DOM,就可能产生冲突,导致界面状态不一致。所以浏览器让 JS 保持单线程,保证"所见即所得"的一致性。

但页面复杂了,任务多了怎么办?比如:

js 复制代码
console.time('主线程耗时')
let sum = 0
for (let i = 0; i < 1e8; i++) {
  sum += i
}
console.timeEnd('主线程耗时')

这段代码会让主线程一直计算,期间页面无法响应点击、滚动、输入,这就是"阻塞"。

为了解决"等待"类任务,JS 设计了 event loop:异步任务先挂起,主线程继续处理后续代码,等异步结果回来再通过任务队列执行回调。这样网络请求、定时器等不会卡住页面。

event loop 解决的是"异步无阻塞",它无法让 CPU 密集计算凭空消失。如果计算本身要占用主线程几秒钟,页面依然会卡。

七、Web Worker:浏览器里的"新线程"

HTML5 提供了 Web Worker:允许 JS 开启一个新的线程,专门处理耗时计算。

它的特点:

  • 独立线程、独立内存
  • 不阻塞主线程
  • 通过消息机制与主线程通信
  • 不能直接操作 DOM

适合场景:LLM 推理、游戏逻辑、大规模数据处理、图像处理等。

基础用法:

js 复制代码
// 主线程
const worker = new Worker(new URL('./worker.js', import.meta.url))
worker.postMessage('start')
worker.onmessage = (e) => {
  console.log('Worker 结果:', e.data)
}
js 复制代码
// worker.js
console.log('worker 线程启动')

self.onmessage = (e) => {
  let sum = 0
  for (let i = 0; i < 1e8; i++) {
    sum += i
  }
  self.postMessage(sum)
}

主线程把任务发给 worker,worker 在独立线程中计算,完成后通过 postMessage 把结果发回来。主线程全程没有被阻塞。

不过,Web Worker 不是免费的午餐。

创建 Worker 线程本身是有开销的:浏览器需要分配独立内存、初始化线程环境,这个成本并不小。而且主线程与 Worker 之间通过 postMessage 通信,数据需要被结构化克隆(序列化/反序列化)。如果频繁传递大量数据,通信成本可能超过计算本身。

因此,轻量任务不建议使用 Worker。例如一个耗时 1ms 的简单计算,直接在主线程执行即可,开启 Worker 反而得不偿失。一般来说,只有遇到明显阻塞、计算耗时较长(例如几百毫秒以上)的 CPU 密集型任务,才值得考虑 Worker。

八、useRef + Web Worker:跨渲染保持 worker 实例

在 React 函数组件里,有一个容易踩的:函数组件每次渲染都会重新执行。

如果直接写:

jsx 复制代码
function App() {
  const worker = new Worker(new URL('./worker.js', import.meta.url))
  // 每次渲染都会创建一个新 worker
}

组件每次重新渲染,都会创建一个新的 worker 实例。这既浪费资源,又可能造成内存泄漏,甚至逻辑混乱。

正确做法是用 useRef 保存 worker:

jsx 复制代码
import { useRef, useEffect } from 'react'

function App() {
  const workerRef = useRef(null)

  useEffect(() => {
    // 组件挂载后,只创建一次 worker
    // 开启一个 worker 线程开销比较大,所以不要在每次渲染时重复创建
    workerRef.current = new Worker(new URL('./worker.js', import.meta.url))

    workerRef.current.onmessage = (e) => {
      console.log('Worker 计算完成:', e.data)
    }

    return () => {
      // 组件卸载时关闭 worker,释放资源
      workerRef.current.terminate()
    }
  }, [])

  const handleStart = () => {
    workerRef.current?.postMessage('start')
  }

  return (
    <div>
      <button onClick={handleStart}>开始耗时计算</button>
    </div>
  )
}

为什么用 useRef 而不是 let

  • 函数组件每次渲染都会重新执行一遍函数,函数体内的普通变量(比如 let worker)都会重新声明、重新赋值,所以每次渲染都会创建一个新的 worker 实例。
  • useRef 的值是 React 保存在组件对应的 fiber 节点上的,不会随函数执行而重置,因此能跨渲染保持引用不变。这样既避免了重复创建 Worker 的昂贵开销,也保证了 worker 实例的稳定性。

这也是 useRef 作为"持久可变对象"的典型应用。

九、串联起来:一条完整知识线

回顾一下:

  1. useState:响应式状态,驱动 UI 更新。
  2. useEffect:处理副作用,在渲染后与外部系统同步。
  3. useRef:可变对象,两个用途------引用 DOM、保存不触发渲染的值。
  4. React 不直接操作 DOM:声明式开发,框架帮你高效更新。
  5. 操作 DOM 的初心:不是为了技术,而是为了用户体验,比如自动聚焦减少用户操作步骤。
  6. JS 单线程 + event loop:保证一致性,异步无阻塞,但计算密集任务仍然会卡。
  7. Web Worker:开辟新线程,处理耗时计算,通过消息机制通信,但创建和通信都有成本,需评估使用。
  8. useRef + Web Worker:在 React 中跨渲染稳定持有 worker 实例,避免重复创建。

这些知识点并不是孤立的。useRef 的非响应式特性,恰好适合保存 worker 这种"不需要渲染、但要长期存在"的对象;而 Web Worker 的存在,又解决了 React 主线程被耗时任务阻塞的问题。但切记,Worker 不是万能的,轻量任务不要滥用,否则性能反而下降。

写在最后

学习 useRef 时,不要只把它当成"获取 DOM 的工具"。它本质是一个 跨渲染、非响应式的可变容器,在很多场景下都能派上用场。而在使用它操作 DOM 时,不妨多想想:这个操作能让用户少点一次、少等一秒、少困惑一点吗?

结合 Web Worker,我们可以把耗时任务安全地挪出主线程,让页面保持流畅。但在使用 Worker 前,请先评估任务的计算量和通信成本,避免为轻量任务开启昂贵的新线程。

如果你也在学习 React Hooks,建议动手跑一跑文中的两个 demo,观察:

  • React DevTools 中 ref 值的变化
  • 页面是否重新渲染
  • worker 是否在独立线程启动
  • 简单任务与复杂任务使用 Worker 的性能差异

如果这篇文章对你有帮助,欢迎点赞、收藏、评论,一键三连支持一下~ 你的每一个反馈都是我继续写作的动力!也欢迎关注我,后续会持续输出 React 前端相关的学习笔记和实战经验。我们下篇文章见!😊

相关推荐
fatcoder3 小时前
玩转Nginx 04 — 反向代理:给 nginx 接上后端
前端·后端·nginx
Data_Journal4 小时前
什么是 CAPTCHA,它是如何工作的?
java·大数据·服务器·前端·数据库
产品设计大观4 小时前
实测墨刀AI生成光伏储能管理后台,快速交付高保真界面和React源码
人工智能·react.js·墨刀
计算机魔术师4 小时前
我看了这个更新,把原来的检索方案推翻了
前端
zhanghaha13144 小时前
HTML系列教程:3_HTML 基础标签 — 标题、段落、超链接、图像
前端·html
李高钢5 小时前
C# WPF Prism 进阶(二):区域(Region)与模块化(Module)
java·前端·数据库
xyphf_和派孔明5 小时前
Vite 与 Webpack 对比及常见面试题
前端·webpack·vite
明月_清风5 小时前
Pi Agent 深度解析:开源极简终端 AI 编码代理的终极指南
前端·后端·ai编程
fatcoder5 小时前
玩转Nginx 03 — location 匹配规则:让不同的路径各回各家
前端·后端·nginx