在前端开发内容学习中,React useRef 详解:DOM 引用、持久化值与 Worker 实例是常见主题。很多人在阅读时会遇到概念分散、步骤不清和注意点难以归纳的问题。本文按照基础概念、操作流程和关键细节,对相关内容进行整理。
在 React 中,我们通常通过状态驱动页面:数据变化,组件重新渲染,界面随之更新。但并不是组件里的每一个值都应该成为状态。

例如:
这些需求有一个共同点:组件需要持有一个可以改变、可以跨渲染保留,却不负责直接驱动页面的对象。这正是 useRef 适合处理的问题。
本文从 useRef 的基本概念出发,依次讲清楚它如何引用 DOM、为什么要配合 useEffect、它与 useState 有何区别,以及怎样用它保存普通值。最后再简单看一下 ref 与 Web Worker 的配合。
在原生 JavaScript 开发中,我们可以主动查询和修改 DOM:找到一个元素,改变它的文本、样式或焦点状态。
React 则提供了另一种开发方式:开发者描述“数据处于某种状态时,界面应该是什么样子”,随后由 React 完成实际的 DOM 更新。比如下面的状态:
const [count, setCount] = useState(0);
当调用 setCount 修改 count 后,React 会重新执行组件,根据最新状态得到新的界面。开发者不必亲自找到页面上的计数元素再修改它。
这就是常说的响应式思路:用数据描述界面,让框架负责 DOM 更新。
不过,“不随意手动操作 DOM”并不等于“永远不能访问 DOM”。有些能力本身就属于 DOM 节点,例如:
遇到这类需求时,可以把 DOM 节点交给 React 管理,再通过 useRef 获得它的引用。
useRef 是 React 提供的 Hook。调用它会得到一个带有 current 属性的对象:
const inputRef = useRef(null);
可以把返回结果理解为下面这样的对象:
{
current: null
}
这里传入的 null 是 current 的初始值。ref 最重要的特征有两个:
current 是可变的,可以保存值或对象;current 不会主动触发重新渲染。所以,可以给 useRef 下一个简洁的定义:
useRef用来为组件提供一个持久的可变对象。这个对象拥有current属性;修改它不会触发组件重新渲染。
它经常用来引用 DOM,但并不只能保存 DOM。数字、对象以及 Worker 实例同样可以放进 current。
先看一个让输入框在挂载后自动获得焦点的完整示例:
import { useEffect, useRef, useState } from "react";
const App = () => {
const [count, setCount] = useState(0);
const inputRef = useRef(null);
useEffect(() => {
console.log(inputRef.current);
inputRef.current.focus();
}, []);
return (
<>
<input
type="text"
ref={inputRef}
placeholder="请输入用户名"
/>
<span>{count}</span>
<button onClick={() => setCount(count + 1)}>增加</button>
</>
);
};
export default App;
这个过程可以拆成三个步骤。
const inputRef = useRef(null);
组件刚开始执行时,输入框还没有真正挂载到页面,所以先使用 null 作为初始值。此时访问 inputRef.current,得到的是 null。
<input ref={inputRef} />
这里不是把 inputRef.current 传给元素,而是把整个 ref 对象交给 JSX 的 ref 属性。React 在输入框挂载完成后,会把对应的 DOM 节点写入 inputRef.current。
于是,挂载前后会出现这样的变化:
// 挂载前
inputRef.current === null
// 挂载后
inputRef.current === 输入框对应的 DOM 节点
useEffect(() => {
inputRef.current.focus();
}, []);
focus() 是输入框 DOM 节点的方法。调用后,用户无需先点击输入框便可以直接输入。
这里把操作放进 useEffect 十分关键。组件函数执行、生成 JSX 时,真实 DOM 还没有挂载完成,current 最开始仍是 null;Effect 会在组件完成挂载后执行,此时才可以得到节点并调用它的方法。
空依赖数组 [] 表示该 Effect 与会变化的响应式数据无关。在这个示例中,它承担的是“组件挂载后让输入框获得焦点”这一工作。
继续观察上面的计数按钮:
const [count, setCount] = useState(0);
<button onClick={() => setCount(count + 1)}>增加</button>
点击按钮会更新 state,React 随后重新执行组件。虽然组件函数再次执行了,但 useRef 不会在每次渲染时都创建一个与之前毫无关系的新引用。React 会保留这个 ref 对象,所以 inputRef.current 仍然指向已经挂载的输入框。
这就是“持久”的含义:只要当前组件实例仍然存在,ref 就可以跨越多次渲染保存引用。
如果在组件函数中打印:
console.log(inputRef.current);
首次渲染阶段可能看到 null,后续由状态引起的渲染中则可以看到已经绑定的 DOM 节点。它反映的正是 DOM 挂载与 ref 赋值的先后顺序。
useRef 和 useState 都可以让数据跨渲染保留下来,也都允许值发生改变,但二者最重要的区别是:值变化后,是否通知 React 重新渲染。
可以将选择标准概括为一句话:
页面需要根据这个值重新显示时,使用
useState;只需要把它保存下来,而不需要由它驱动页面时,使用useRef。
计数器显示在页面上,计数变化后页面也必须变化,所以正常情况下应当使用 state:
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
输入框 DOM 本身不是页面要渲染的业务数据,获得节点引用也不应该再次刷新页面,所以它更适合由 ref 保存。
ref 的 current 可以指向 DOM,也可以直接保存一个数字:
import { useRef } from "react";
const App = () => {
const numRef = useRef(0);
return (
<div onClick={() => {
numRef.current += 1;
}}>
{numRef.current}
</div>
);
};
export default App;
每次点击都会执行:
numRef.current += 1;
所以 ref 内部保存的数字确实发生了变化。但页面上的数字不会跟着立即变化,因为修改 current 不会触发重新渲染。
这不是错误,而是 useRef 的设计特点。React 不会因为一个普通可变引用发生变化就重新执行组件。
如果只是为了演示,想在更新 ref 后强制组件再渲染一次,可以额外使用一个 state:
import { useRef, useState } from "react";
const App = () => {
const numRef = useRef(0);
const [, forceRender] = useState(0);
console.log(numRef.current);
return (
<div
onClick={() => {
numRef.current += 1;
forceRender(value => value + 1);
}}
>
{numRef.current}
</div>
);
};
export default App;
useState 返回数组中的第一个元素是状态值。这里并不需要读取它,所以用空位跳过:
const [, forceRender] = useState(0);
点击时发生了两件事:
numRef.current += 1;
forceRender(value => value + 1);
第一行修改 ref 保存的数字,但它本身不会刷新页面;第二行更新 state,通知 React 重新渲染。组件再次执行 JSX 时,读取到的已经是最新的 numRef.current,于是页面数字发生变化。
这里要注意,真正触发渲染的是 forceRender 对 state 的更新,不是 ref。这个写法适合用来观察二者的区别。如果页面本来就需要展示并持续更新这个数字,直接把数字放进 useState 会更符合它的职责。
同时,函数式更新确保每次都从上一次状态继续递增:
forceRender(value => value + 1);
如果只调用 forceRender(),传入的新状态会是 undefined:第一次可能因为 0 变成 undefined 而触发渲染,后续仍然更新为 undefined 时,就不能用它稳定地演示“每次点击都刷新”。
到这里可以看到,DOM 引用和普通数字看似是两种不同的需求,底层却使用了同一种能力:
const inputRef = useRef(null);
const numRef = useRef(0);
对 DOM:
<input ref={inputRef} />
React 在挂载后负责把节点放入 inputRef.current。
对普通值:
numRef.current += 1;
开发者自己读写 numRef.current。
二者共同满足下面两个条件:
所以,useRef 的本质不只是“获取 DOM 的 Hook”,而是一个持久的可变容器。引用 DOM 是它最常见、也最直观的应用之一。
JavaScript 主线程还要处理页面渲染和用户交互。如果把耗时计算直接放在主线程中执行,就可能影响页面响应。浏览器提供的 Web Worker 可以在独立线程中承担这类计算,再通过消息机制与主线程联系。
Worker 实例也可以由 ref 持有:
import { useEffect, useRef } from "react";
function App() {
const workerRef = useRef(null);
useEffect(() => {
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
);
}, []);
return <></>;
}
export default App;
Worker 文件中先用一条日志确认线程已经启动:
console.log("worker online");
这段代码仍然体现了 ref 的两个核心特点:Worker 实例需要在组件存在期间被持续引用,但把实例保存下来并不需要触发页面渲染,所以用 workerRef.current 承接它很合适。
创建动作放在 useEffect 中,让它发生在组件挂载之后:
workerRef.current = new Worker(
new URL("./worker.js", import.meta.url)
);
new URL("./worker.js", import.meta.url) 用当前模块地址解析 Worker 文件,再把得到的地址交给 Worker 构造函数。这里的重点仍是 ref 如何保存实例;Worker 的消息发送、任务计算等内容不再展开。
不会。ref.current 是可变的,但它不是响应式状态。要让某个值的变化立即反映到 JSX 中,应优先考虑 useState。
不能。执行 useRef(null) 时,current 的初始值就是 null。只有 JSX 中的元素完成挂载,React 才会把节点写入 current。所以挂载后的 DOM 操作通常放在 useEffect 中。
不是。current 可以指向任何值。DOM 节点、数字和 Worker 实例都可以保存,只要这个值需要跨渲染持久存在,并且它的改变不需要主动驱动页面更新。
二者虽然都能保存数据,但更新行为不同。如果用 ref 保存一个需要展示的计数,还必须另找 state 触发渲染,反而让逻辑变复杂。选择时应先判断这个数据是否参与页面显示。
理解 useRef,关键不是死记某个 API,而是抓住“持久、可变、非响应式”三个词:
ref.current 保存和修改内容;current 不会主动触发重新渲染。当需要操作输入框焦点时,可以创建 ref,通过 JSX 的 ref 属性绑定 DOM,并在 useEffect 中调用节点方法;当需要保存不驱动页面更新的普通值或实例时,也可以把它放在 current 中。
最后再用一句话区分 useState 与 useRef:
useState负责“值变了,页面也要变”;useRef负责“把这个可变内容一直保存下来,但不必通知页面”。
掌握这条边界,便能判断什么时候应该交给 React 的状态系统,什么时候只需要一个稳定的引用。