一个 useRef,三种人生:从 DOM 引用到 Web Worker

一个 useRef,三种人生:从 DOM 引用到 Web Worker

很多人刚学 React 时,对 useRef 的印象只有一句话:拿 DOM 节点的。

面试被问到 useRefuseState 的区别,也能马上背出:一个会触发重新渲染,一个不会。

但这句话背后真正重要的是什么?为什么一个 current 属性,既能指向输入框,也能保存定时器,还能挂住一个 Web Worker 实例?

本文用三个场景把 useRef 拆开:从 DOM 引用,到不参与渲染的可变值,再到 Web Worker 这类外部对象。看完你会发现,useRef 的核心并不是"拿 DOM",而是:

在组件多次渲染之间,保存一个稳定、可变、不会主动触发渲染的引用。

一、React 为什么不鼓励我们到处直接操作 DOM?

早期前端开发经常这样写:

js 复制代码
document.getElementById("title").textContent = "Hello";
document.querySelector(".box").style.color = "red";

这是典型的命令式 DOM 编程:我们直接找到节点,然后修改它的内容或样式。

它并不是"不能用",但当页面变复杂后,问题会逐渐出现:

  • 多处代码可能同时修改同一个节点
  • 数据和 DOM 状态容易脱节
  • 列表、条件分支、事件更新需要手动维护
  • 组件销毁后,遗留的事件、定时器或第三方实例可能没有清理

React 采用的是声明式思路:

jsx 复制代码
// 命令式:自己找到节点并修改
input.value = "hello";

// 声明式:描述 UI 应该是什么样子
setValue("hello");

更准确地说,React 并没有"消灭 DOM",而是接管了大多数由状态驱动的 DOM 更新。你负责描述 UI,React 负责把状态变化落实到 DOM。

不过,现实中的前端总会遇到 React 不应该直接接管的对象:

  • 页面加载后让输入框自动聚焦
  • 滚动到某个元素,或读取元素尺寸
  • 集成地图、图表、播放器等非 React 库
  • 保存定时器 ID、上一次的值或请求控制器
  • 持有 Web Worker、WebSocket 等外部实例

这时就需要 useRef

二、先用一句话理解 useRef

调用 useRef(initialValue) 会得到一个稳定的对象:

js 复制代码
const ref = useRef(initialValue);

// ref 的形状可以理解为:
{
  current: initialValue
}

这个对象有两个关键特征:

  1. 组件重新渲染时,ref 对象本身仍然是同一个对象
  2. 修改 ref.current 不会自动触发重新渲染

所以可以把它看成一个"挂在组件生命周期里的小盒子"。React 帮你保管这个盒子,但不会因为盒子里的内容变了,就重新绘制页面。

这也是它和 useState 的根本区别:

对比项 useState useRef
更新方式 调用 setter 直接修改 current
是否触发重新渲染 不会
读取方式 当前渲染中的 state 快照 读取 ref.current 的当前值
适合保存 会影响 UI 的状态 不直接参与 UI 的可变引用

这里的"不会触发渲染"不是缺点,而是一个明确的设计选择:如果数据变化需要页面立刻反映,就用 state;如果只是给副作用或外部对象使用,就可以考虑 ref。

三、面孔一:DOM 引用,让输入框自动聚焦

这是 useRef 最常见的用法。场景很简单:页面打开后,输入框自动获得焦点。

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

export default function LoginForm() {
  const inputRef = useRef(null);

  useEffect(() => {
    inputRef.current?.focus();
  }, []);

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

这段代码有三个关键点:

  • useRef(null) 创建一个 current 初始为 null 的容器
  • React 挂载 <input> 后,会把真实 DOM 节点写入 inputRef.current
  • useEffect 在提交 DOM 后执行,此时才适合调用 focus()

组件卸载时,React 会把 DOM ref 清回 null。因此不要把 DOM 节点当作永久存在的全局对象。

为什么不直接用 autoFocus?

如果只是"首次加载时聚焦",autoFocus 完全够用:

jsx 复制代码
<input autoFocus />

useRef 的能力更通用。拿到 DOM 节点后,你还可以调用:

js 复制代码
inputRef.current?.focus();
inputRef.current?.scrollIntoView({ behavior: "smooth" });
const height = inputRef.current?.getBoundingClientRect().height;

经验上,能用 React 属性和状态解决的 UI,不要为了炫技使用 ref;确实需要调用原生 API 时,再让 ref 出场。

四、面孔二:可变值,保存不参与渲染的数据

useRefcurrent 不要求必须是 DOM 节点,数字、字符串、对象、函数都可以保存。

先看一个最直观的例子:

jsx 复制代码
import { useRef, useState } from "react";

export default function RefCounter() {
  const numRef = useRef(0);
  const [, forceRender] = useState(0);

  function handleClick() {
    numRef.current += 1;
    forceRender((version) => version + 1);
  }

  return (
    <button onClick={handleClick}>
      ref 计数:{numRef.current}
    </button>
  );
}

注意,真正修改 ref 的代码是:

js 复制代码
numRef.current += 1;

这行代码本身不会触发任何渲染。示例中的 forceRender 只是为了让页面重新执行组件函数,从而把 ref 里的新数字显示出来。

实际项目里,如果这个数字就是用户要看到的 UI 状态,应该直接使用 useState

jsx 复制代码
const [count, setCount] = useState(0);

setCount((value) => value + 1);

把 ref 当成"不会自动更新页面的 state",通常会让代码变得难以理解。它更适合保存 UI 不需要直接展示的值。

常见场景:保存定时器 ID

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

export default function Polling() {
  const timerRef = useRef(null);

  useEffect(() => {
    timerRef.current = window.setInterval(() => {
      console.log("刷新数据");
    }, 5000);

    return () => {
      if (timerRef.current !== null) {
        window.clearInterval(timerRef.current);
      }
    };
  }, []);

  return <p>轮询中</p>;
}

定时器 ID 不需要显示在页面上,但清理副作用时必须拿到它。用 ref 保存,既能跨渲染保持引用,也不会因为保存 ID 而触发一次额外渲染。

常见场景:保存上一次的值

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

function SearchResult({ keyword }) {
  const previousKeywordRef = useRef(keyword);

  useEffect(() => {
    if (previousKeywordRef.current !== keyword) {
      console.log("关键词从", previousKeywordRef.current, "变成", keyword);
    }

    previousKeywordRef.current = keyword;
  }, [keyword]);

  return <p>当前关键词:{keyword}</p>;
}

常见场景:保存最新回调或标记位

例如某个副作用需要读取最新配置,但不希望因为配置对象变化就重新创建外部连接,可以把最新配置同步到 ref,再由事件处理函数读取 configRef.current

不过,这种写法要谨慎:ref 绕开了 React 的响应式更新模型。它适合解决"读取最新值"的问题,不应该代替正常的数据流。

五、面孔三:外部对象,把 Web Worker 挂在 ref 上

第三种场景更接近工程实践:把一个不属于 React 的外部实例交给 useRef 管理。

先理解为什么需要 Worker

浏览器页面的 JavaScript 通常运行在主线程上。主线程既要处理 React 更新、点击和滚动,也要执行 JavaScript 计算。

如果在主线程执行很重的同步循环,事件循环就没有机会及时处理用户操作:页面会卡顿,甚至暂时无响应。

js 复制代码
// 不要在主线程执行这种大规模同步计算
for (let i = 0; i < 1000000000; i += 1) {
  // 耗时计算
}

async/awaitPromise 主要解决的是"等待期间不阻塞后续代码组织"的问题;它们不会自动把同步计算搬到另一个线程。计算最终仍然会在执行它的线程上占用 CPU。

Web Worker 提供了一个独立的脚本执行上下文。主线程通过消息发送任务,Worker 完成计算后再把结果发回来:

text 复制代码
主线程(负责 UI)                         Worker 线程(负责计算)
      │                                          │
      │──── postMessage(任务) ─────────────────→│
      │                                          │  执行耗时计算
      │  继续响应点击和滚动                       │
      │←──── onmessage(计算结果) ────────────────│
      │                                          │
      │  setResult(结果)                         │

错误写法:在渲染函数里创建 Worker

下面这种写法看起来直接,但不应该使用:

jsx 复制代码
function App() {
  const workerRef = useRef(null);

  // 错误:渲染函数每执行一次,就可能创建一个新的 Worker
  workerRef.current = new Worker(
    new URL("./worker.js", import.meta.url)
  );

  return <div>计算中...</div>;
}

React 组件函数可能因为 state 更新、父组件更新、开发环境 Strict Mode 等原因多次执行。渲染阶段应该保持纯粹,不能在里面创建 Worker、绑定事件或发送任务,否则会出现重复线程、重复监听、重复计算和无法清理的问题。

正确写法:在 effect 中创建、在 cleanup 中销毁

jsx 复制代码
import { useEffect, useRef, useState } from "react";

export default function WorkerDemo() {
  const workerRef = useRef(null);
  const [result, setResult] = useState(null);
  const [status, setStatus] = useState("准备开始");

  useEffect(() => {
    const worker = new Worker(
      new URL("./worker.js", import.meta.url),
      { type: "module" }
    );

    workerRef.current = worker;

    worker.onmessage = (event) => {
      setResult(event.data);
      setStatus("计算完成");
    };

    worker.onerror = () => {
      setStatus("计算失败");
    };

    setStatus("计算中...");
    worker.postMessage({ task: "sum" });

    return () => {
      worker.terminate();
      workerRef.current = null;
    };
  }, []);

  return (
    <div>
      <p>{status}</p>
      {result !== null && <p>结果:{result}</p>}
    </div>
  );
}

这里的职责分工很清楚:

  • workerRef 保存 Worker 实例,保证重渲染时仍然能拿到同一个对象
  • statusresult 属于 UI 数据,因此使用 useState
  • useEffect 负责创建 Worker、绑定消息监听和发送初始任务
  • cleanup 负责终止 Worker,防止组件卸载后后台线程继续运行

对应的 worker.js

js 复制代码
self.onmessage = (event) => {
  if (event.data?.task !== "sum") {
    return;
  }

  let result = 0;

  for (let i = 0; i < 100000000; i += 1) {
    result += i;
  }

  self.postMessage(result);
};

示例使用 new URL("./worker.js", import.meta.url),这是 Vite 等现代构建工具常见的 Worker 引入方式。不同脚手架的 Worker 配置可能略有差异,实际项目需要以构建工具文档为准。

为什么不用 state 保存 Worker?

严格来说,useState 也能保存一个对象引用,并不是"用了 state 就一定每次渲染都重新创建"。真正的问题是:Worker 实例本身不是 UI 状态,保存它不需要触发渲染,使用 ref 语义更准确,也更方便在 effect 和事件处理函数中读取。

不要把"用 state 保存"简单说成"每次渲染都会创建新 Worker";只要初始化方式正确,state 也可以保持引用。工程上选择 ref,是因为它表达了"这是一个外部可变实例,不负责驱动 UI"。

六、三个 Hook 如何配合?

把这三个场景放在一起看:

Hook 负责什么 更新后是否重渲染 典型内容
useState 保存会影响 UI 的状态 加载状态、结果、表单值
useRef 保存跨渲染的可变引用 不会 DOM、timer、Worker、上一次的值
useEffect 安排副作用的执行与清理 本身不负责保存值 创建实例、监听事件、请求数据

可以用一句话记忆:

state 管页面要显示什么,ref 管外部对象在哪里,effect 管什么时候连接和断开。

七、常见误区

1. ref 改了,为什么页面不更新?

因为修改 current 不会触发渲染:

js 复制代码
valueRef.current = nextValue;

如果页面必须马上显示 nextValue,就应该把它放进 state:

js 复制代码
setValue(nextValue);

2. 能不能在渲染阶段读取和修改 ref?

读取某些稳定 ref 通常没有问题,但不要在渲染阶段执行会产生外部影响的操作,例如创建 Worker、绑定事件、启动定时器或调用第三方实例。修改 ref 也应尽量放在事件处理函数或 effect 中,避免让渲染过程依赖隐式副作用。

3. useRef 能不能跨页面永久保存数据?

不能。ref 的生命周期属于当前组件实例。组件卸载后,ref 也会随之失效;需要跨页面或跨组件共享的数据,应考虑状态提升、Context、状态管理库或持久化存储。

4. useRefcreateRef 有什么区别?

函数组件中通常使用 useRef。它在多次渲染之间保持同一个 ref 对象;createRef 更常见于 class 组件,若在函数组件每次渲染时直接调用,可能得到新的对象。

八、面试怎么回答 useRef?

可以这样回答:

useRef 返回一个在组件多次渲染之间保持稳定的对象,对象通过 current 保存值。修改 current 不会触发组件重新渲染,因此它适合保存 DOM 节点、定时器 ID、上一次的值,以及 Web Worker、WebSocket 等外部实例。如果数据变化需要驱动 UI 更新,应使用 useState;如果涉及创建、订阅或销毁外部资源,则通常配合 useEffect 管理时机和清理。

这段回答包含了面试官真正想听的四个关键词:稳定引用、可变 current、不触发渲染、副作用配合

九、总结:useRef 的三种面孔

useRef 的本质是一个持久的可变容器。它不是只能拿 DOM,而是可以承载三类东西:

面孔 current 指向 典型场景 核心代码
DOM 引用 DOM 节点 自动聚焦、滚动、测量 inputRef.current?.focus()
可变值 数字、字符串、对象 timer ID、上一次的值、标记位 previousRef.current = value
外部对象 Worker、WebSocket、图表实例 后台计算、第三方库集成 workerRef.current = worker

最后记住三句话:

  • useState 管数据,改了以后页面需要重新绘制
  • useRef 管引用,改了以后不会主动触发绘制
  • useEffect 管时机,负责连接外部世界,也负责清理

理解这三者的分工,useRef 就不再是"操作 DOM 的特殊 API",而会变成 React 中管理外部资源和跨渲染引用的一件基础工具。


相关推荐
无人生还1 小时前
从 Vue3 到 React · 快速上手系列第 13 篇:工程化与综合实战(Vite + Todo 应用)
前端·vue.js·react.js
晴殇i1 小时前
用 TRAE Work 搞定客户项目介绍,开发再也不用头疼写汇报 PPT
前端·后端
Cache技术分享1 小时前
488. Java 反射 - 动态创建数组
前端·后端
Synmbrf1 小时前
ECharts 饼图鼠标悬浮后标签 title 消失的问题
前端·javascript
Canace1 小时前
用 AI 写了一天代码,为什么反而更累了?
前端·人工智能·ai编程
Vhen1 小时前
Taro 封装小程序toast提示组件
前端
用户250694921611 小时前
React项目打包为.next文件部署
前端
tryCbest1 小时前
Html设置网站图标
前端·html
半个落月1 小时前
从页面跳转到前端路由:手写一个简单的 HashRouter
前端