React useRef 详解:DOM 引用、持久化值与 Worker 实例

React useRef 详解:从获取 DOM 到保存可变值

在 React 中,我们通常通过状态驱动页面:数据变化,组件重新渲染,界面随之更新。但并不是组件里的每一个值都应该成为状态。

例如:

  • 希望输入框在页面加载后自动获得焦点;
  • 希望在多次渲染之间一直保存某个值;
  • 希望保存一个 Worker 实例,但不希望保存动作导致页面刷新。

这些需求有一个共同点:组件需要持有一个可以改变、可以跨渲染保留,却不负责直接驱动页面的对象。这正是 useRef 适合处理的问题。

本文从 useRef 的基本概念出发,依次讲清楚它如何引用 DOM、为什么要配合 useEffect、它与 useState 有何区别,以及怎样用它保存普通值。最后再简单看一下 ref 与 Web Worker 的配合。

一、先理解 React 为什么不鼓励随意操作 DOM

在原生 JavaScript 开发中,我们可以主动查询和修改 DOM:找到一个元素,改变它的文本、样式或焦点状态。

React 则提供了另一种开发方式:开发者描述"数据处于某种状态时,界面应该是什么样子",随后由 React 完成实际的 DOM 更新。比如下面的状态:

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

当调用 setCount 修改 count 后,React 会重新执行组件,根据最新状态得到新的界面。开发者不必亲自找到页面上的计数元素再修改它。

这就是常说的响应式思路:用数据描述界面,让框架负责 DOM 更新。

不过,"不随意手动操作 DOM"并不等于"永远不能访问 DOM"。有些能力本身就属于 DOM 节点,例如:

  • 让输入框获得焦点;
  • 获取某个真实节点对象;
  • 调用节点自身提供的方法。

遇到这类需求时,可以把 DOM 节点交给 React 管理,再通过 useRef 获得它的引用。

二、useRef 到底是什么

useRef 是 React 提供的 Hook。调用它会得到一个带有 current 属性的对象:

jsx 复制代码
const inputRef = useRef(null);

可以把返回结果理解为下面这样的对象:

js 复制代码
{
  current: null
}

这里传入的 nullcurrent 的初始值。ref 最重要的特征有两个:

  1. current 是可变的,可以保存值或对象;
  2. ref 对象会在组件的多次渲染之间被保留下来,修改 current 不会主动触发重新渲染。

因此,可以给 useRef 下一个简洁的定义:

useRef 用来为组件提供一个持久的可变对象。这个对象拥有 current 属性;修改它不会触发组件重新渲染。

它经常用来引用 DOM,但并不只能保存 DOM。数字、对象以及 Worker 实例同样可以放进 current

三、使用 ref 获取输入框 DOM

先看一个让输入框在挂载后自动获得焦点的完整示例:

jsx 复制代码
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;

这个过程可以拆成三个步骤。

1. 创建 ref 对象

jsx 复制代码
const inputRef = useRef(null);

组件刚开始执行时,输入框还没有真正挂载到页面,因此先使用 null 作为初始值。此时访问 inputRef.current,得到的是 null

2. 通过 JSX 的 ref 属性完成绑定

jsx 复制代码
<input ref={inputRef} />

这里不是把 inputRef.current 传给元素,而是把整个 ref 对象交给 JSX 的 ref 属性。React 在输入框挂载完成后,会把对应的 DOM 节点写入 inputRef.current

于是,挂载前后会出现这样的变化:

js 复制代码
// 挂载前
inputRef.current === null

// 挂载后
inputRef.current === 输入框对应的 DOM 节点

3. 挂载后调用 DOM 方法

jsx 复制代码
useEffect(() => {
  inputRef.current.focus();
}, []);

focus() 是输入框 DOM 节点的方法。调用后,用户无需先点击输入框便可以直接输入。

这里把操作放进 useEffect 十分关键。组件函数执行、生成 JSX 时,真实 DOM 还没有挂载完成,current 最开始仍是 null;Effect 会在组件完成挂载后执行,此时才可以得到节点并调用它的方法。

空依赖数组 [] 表示该 Effect 与会变化的响应式数据无关。在这个示例中,它承担的是"组件挂载后让输入框获得焦点"这一工作。

四、为什么点击按钮重新渲染后,ref 没有丢失

继续观察上面的计数按钮:

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

<button onClick={() => setCount(count + 1)}>增加</button>

点击按钮会更新 state,React 随后重新执行组件。虽然组件函数再次执行了,但 useRef 不会在每次渲染时都创建一个与之前毫无关系的新引用。React 会保留这个 ref 对象,所以 inputRef.current 仍然指向已经挂载的输入框。

这就是"持久"的含义:只要当前组件实例仍然存在,ref 就可以跨越多次渲染保存引用。

如果在组件函数中打印:

jsx 复制代码
console.log(inputRef.current);

首次渲染阶段可能看到 null,后续由状态引起的渲染中则可以看到已经绑定的 DOM 节点。它反映的正是 DOM 挂载与 ref 赋值的先后顺序。

五、useRef 和 useState:都能保存值,但职责不同

useRefuseState 都可以让数据跨渲染保留下来,也都允许值发生改变,但二者最重要的区别是:值变化后,是否通知 React 重新渲染。

对比项 useState useRef
保存位置 state 值 ref 对象的 current 属性
能否修改 可以,通过 setter 更新 可以,直接修改 current
修改后是否触发渲染 不会
更关注什么 页面需要展示的业务状态 DOM 引用、无需驱动界面的可变对象

可以将选择标准概括为一句话:

页面需要根据这个值重新显示时,使用 useState;只需要把它保存下来,而不需要由它驱动页面时,使用 useRef

计数器显示在页面上,计数变化后页面也必须变化,所以正常情况下应当使用 state:

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

return (
  <button onClick={() => setCount(count + 1)}>
    {count}
  </button>
);

输入框 DOM 本身不是页面要渲染的业务数据,获得节点引用也不应该再次刷新页面,所以它更适合由 ref 保存。

六、useRef 也可以保存普通值

ref 的 current 可以指向 DOM,也可以直接保存一个数字:

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

const App = () => {
  const numRef = useRef(0);

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

export default App;

每次点击都会执行:

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

因此 ref 内部保存的数字确实发生了变化。但页面上的数字不会跟着立即变化,因为修改 current 不会触发重新渲染。

这不是错误,而是 useRef 的设计特点。React 不会因为一个普通可变引用发生变化就重新执行组件。

借助 state 观察 ref 的最新值

如果只是为了演示,想在更新 ref 后强制组件再渲染一次,可以额外使用一个 state:

jsx 复制代码
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 返回数组中的第一个元素是状态值。这里并不需要读取它,所以用空位跳过:

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

点击时发生了两件事:

jsx 复制代码
numRef.current += 1;
forceRender(value => value + 1);

第一行修改 ref 保存的数字,但它本身不会刷新页面;第二行更新 state,通知 React 重新渲染。组件再次执行 JSX 时,读取到的已经是最新的 numRef.current,于是页面数字发生变化。

这里要注意,真正触发渲染的是 forceRender 对 state 的更新,不是 ref。这个写法适合用来观察二者的区别。如果页面本来就需要展示并持续更新这个数字,直接把数字放进 useState 会更符合它的职责。

同时,函数式更新确保每次都从上一次状态继续递增:

jsx 复制代码
forceRender(value => value + 1);

如果只调用 forceRender(),传入的新状态会是 undefined:第一次可能因为 0 变成 undefined 而触发渲染,后续仍然更新为 undefined 时,就不能用它稳定地演示"每次点击都刷新"。

七、把两种 ref 用法放在一起理解

到这里可以看到,DOM 引用和普通数字看似是两种不同的需求,底层却使用了同一种能力:

jsx 复制代码
const inputRef = useRef(null);
const numRef = useRef(0);

对于 DOM:

jsx 复制代码
<input ref={inputRef} />

React 在挂载后负责把节点放入 inputRef.current

对于普通值:

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

开发者自己读写 numRef.current

二者共同满足下面两个条件:

  • 需要在多次渲染之间保留;
  • 它的改变不应该自动触发渲染。

因此,useRef 的本质不只是"获取 DOM 的 Hook",而是一个持久的可变容器。引用 DOM 是它最常见、也最直观的应用之一。

八、简单了解:用 ref 保存 Worker 实例

JavaScript 主线程还要处理页面渲染和用户交互。如果把耗时计算直接放在主线程中执行,就可能影响页面响应。浏览器提供的 Web Worker 可以在独立线程中承担这类计算,再通过消息机制与主线程联系。

Worker 实例也可以由 ref 持有:

jsx 复制代码
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 文件中先用一条日志确认线程已经启动:

js 复制代码
console.log("worker online");

这段代码仍然体现了 ref 的两个核心特点:Worker 实例需要在组件存在期间被持续引用,但把实例保存下来并不需要触发页面渲染,因此用 workerRef.current 承接它很合适。

创建动作放在 useEffect 中,让它发生在组件挂载之后:

jsx 复制代码
workerRef.current = new Worker(
  new URL("./worker.js", import.meta.url)
);

new URL("./worker.js", import.meta.url) 用当前模块地址解析 Worker 文件,再把得到的地址交给 Worker 构造函数。这里的重点仍是 ref 如何保存实例;Worker 的消息发送、任务计算等内容不再展开。

九、常见理解误区

误区 1:修改 current 后,页面应该自动更新

不会。ref.current 是可变的,但它不是响应式状态。要让某个值的变化立即反映到 JSX 中,应优先考虑 useState

误区 2:创建 ref 后立刻就能拿到 DOM

不能。执行 useRef(null) 时,current 的初始值就是 null。只有 JSX 中的元素完成挂载,React 才会把节点写入 current。所以挂载后的 DOM 操作通常放在 useEffect 中。

误区 3:useRef 只能引用 DOM

不是。current 可以指向任何值。DOM 节点、数字和 Worker 实例都可以保存,只要这个值需要跨渲染持久存在,并且它的改变不需要主动驱动页面更新。

误区 4:ref 和 state 可以随意互换

二者虽然都能保存数据,但更新行为不同。如果用 ref 保存一个需要展示的计数,还必须另找 state 触发渲染,反而让逻辑变复杂。选择时应先判断这个数据是否参与页面显示。

十、总结

理解 useRef,关键不是死记某个 API,而是抓住"持久、可变、非响应式"三个词:

  • 持久:组件重新渲染时,ref 对象不会丢失;
  • 可变 :可以通过 ref.current 保存和修改内容;
  • 非响应式 :修改 current 不会主动触发重新渲染。

当需要操作输入框焦点时,可以创建 ref,通过 JSX 的 ref 属性绑定 DOM,并在 useEffect 中调用节点方法;当需要保存不驱动页面更新的普通值或实例时,也可以把它放在 current 中。

最后再用一句话区分 useStateuseRef

useState 负责"值变了,页面也要变";useRef 负责"把这个可变内容一直保存下来,但不必通知页面"。

掌握这条边界,便能判断什么时候应该交给 React 的状态系统,什么时候只需要一个稳定的引用。

相关推荐
阿黎梨梨1 小时前
TypeScript 类型编程:从新手到 Harness 工程实践
前端
黄金决明子1 小时前
Vue3 + Vite 打包后打开空白?
前端
张龙6871 小时前
终端效率翻倍实战:fzf + zoxide + ripgrep + bat 组合拳,告别重复敲命令
前端
宿6741 小时前
vue3-env环境
前端·vue.js
蔬菜_1 小时前
前端转全栈-day5(数组、list、set)
java·前端·数据结构·list
汉堡大王95271 小时前
Vue 3 + TS + Element Plus 实战:如何从零搭建企业级违章记录管理 SaaS 前端
前端·javascript·vue.js
minimoon_jojo1 小时前
Ant Design 树形表格渲染原理
前端
paopaokaka_luck2 小时前
基于springboot3+vue3的音乐推荐系统(协同过滤算法、Echarts图形化分析)
前端·echarts
八号当铺2 小时前
使用 Figma Agent Kit:插件 + MCP + 还原 Skill,打通本地设计协作
前端·人工智能·ai编程