深入理解 React useRef:从 DOM 操作到持久化存值的完整指南

7 阅读8分钟

在 React Hooks 体系中,useStateuseEffect 几乎是每位开发者的日常标配,但 useRef 却常常被误解或低估。很多人只知道它用来获取 DOM,却忽略了它 "持久可变对象" 的核心本质。本文将从底层原理出发,结合两个典型场景,带你彻底搞懂 useRef 的设计哲学与正确用法。

一、React 常用 Hooks 三剑客

在正式讲解 useRef 之前,我们先快速回顾另外两个核心 Hook,建立对比坐标系。

useState:响应式状态

useState 是 React 响应式编程的基石。它声明的状态与视图绑定,状态变更会自动触发组件重新渲染,页面同步展示最新值。这是 React 声明式编程的核心体现 —— 数据驱动视图。

tsx

const [count, setCount] = useState(0);

调用 setCount 后,React 内部会调度一次重渲染,组件函数重新执行,JSX 重新计算,DOM 高效更新。整个过程开发者不需要关心 DOM 操作,只需要关心数据状态。

useEffect:副作用处理

useEffect 用来处理渲染之外的 "副作用",比如数据请求、订阅、手动修改 DOM 等。它在组件渲染完成后异步执行,通过依赖数组控制执行时机,是 React 函数组件对接外部世界的主要窗口。

tsx

useEffect(() => {
  // 副作用逻辑
}, [deps]);

useRef:持久可变对象

useRef 返回一个带有 current 属性的普通 JavaScript 对象。它有两个最关键的特性:

  1. 跨渲染周期保持引用不变:组件无论重渲染多少次,返回的 ref 对象始终是同一个引用
  2. 修改 current 不会触发重渲染:这是它与 useState 最本质的区别

正是这两个特性,让 useRef 承担了两类核心职责:引用 DOM 节点、存储不需要驱动视图的持久化变量。

二、为什么 React 不推崇直接操作 DOM

在理解 useRef 操作 DOM 之前,我们需要先想明白一个问题:React 为什么要把 DOM 操作藏起来?

DOM 编程的性能本质

JavaScript 运行在 V8 引擎中,而 DOM 树归渲染引擎管理。两者分属不同线程,每次 JS 操作 DOM 都要跨越 "桥" 进行通信,这个桥接过程本身就有性能开销。频繁的 DOM 读写还会触发强制同步布局(Reflow),是前端性能优化的重点治理对象。

开发范式的跃迁

在 React、Vue 出现之前,前端是典型的命令式编程:找到元素 → 修改内容 → 绑定事件。开发者需要亲自管理每一步 DOM 操作,状态和视图的同步全靠人工维护,代码量一大就容易出 Bug。

React 带来了声明式编程:你只需要描述 "数据是什么样子,视图就应该是什么样子",框架帮你完成 DOM 的 diff、更新、复用。useState + 数据绑定的模式,直接重构了前端开发的思维方式。

但这并不意味着 DOM 操作被完全禁止。总有一些场景,框架封装的能力不够用 —— 比如输入框自动聚焦、获取元素宽高、操作 canvas 等。这时,useRef 就成了 React 留给开发者的 "安全出口"。

三、场景一:useRef 引用 DOM 节点

这是 useRef 最经典的使用场景。React 不推荐你直接操作 DOM,但如果你确实需要,它提供了标准的、可预测的方式。

基础用法示例

tsx

import { useRef, useEffect, useState } from 'react';

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

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

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

逐行拆解执行逻辑

第一步:初始化 ref

tsx

const inputRef = useRef(null);

组件首次渲染时,useRef(null) 创建并返回一个对象 { current: null }。此时 DOM 还没挂载,current 自然是 null。如果你在函数体顶部打印 inputRef.current,会看到输出 null

第二步:JSX 中绑定 ref 属性

tsx

<input ref={inputRef} />

这是 React 的特殊语法。在组件提交阶段(commit phase),React 会把真实创建好的 DOM 元素赋值给 inputRef.current。绑定完成后,current 就指向了这个真实的 input DOM 节点。

第三步:useEffect 中使用 DOM

tsx

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

useEffect 的空依赖数组保证了这段代码只在组件挂载完成后执行一次。此时 DOM 已经挂载完毕,inputRef.current 指向真实元素,可以安全地调用原生 DOM API —— focus() 让输入框自动获得焦点。

这个例子的产品意义也很明确:用户打开页面直接就能输入,不用再手动点一下输入框,体验更流畅。前端开发的职责,本质上就是打造这些细腻的用户体验。

补充:自动聚焦其实也可以用原生 autoFocus 属性实现,这里主要用作演示 ref 的用法。

关键注意点

  • 不要在组件函数体顶部直接访问 ref.current,首次渲染时一定是 null
  • DOM 相关操作统一放在 useEffect 或事件回调中执行
  • 组件卸载时,React 会自动把 ref.current 重置为 null

四、场景二:useRef 存储非响应式变量

很多人不知道,useRef 不仅能存 DOM,还能存任意类型的值。当你需要一个 "可变、持久、但改了不想触发渲染" 的变量时,useRef 就是最佳选择。

经典示例:ref + 强制渲染

tsx

const App = () => {
  const numRef = useRef(0);
  const [, forceRender] = useState(0);
  
  console.log(numRef.current);

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

export default App;

这段代码初看很反直觉:既然要显示到页面上,为什么不直接用 useState?我们逐行拆解它的运行机制,你就能理解 useRef 的行为模式。

逐行深度解析

第一行:声明 ref 存值

tsx

const numRef = useRef(0);

初始值为 0,返回 { current: 0 }。重点是:后续每次组件重渲染,返回的都是同一个对象,current 的值会被保留,不会重置回 0。

第二行:只拿更新函数

tsx

const [, forceRender] = useState(0);

数组解构中用逗号跳过第一个值,只取出更新函数 forceRender。这个 state 本身的值我们完全不用,它存在的唯一意义就是提供一个能触发重渲染的函数。

这是一种 "强制刷新" 的技巧:调用 forceRender() 会让 React 调度一次重渲染,但我们并不关心 state 的具体值。

第三行:打印验证时机

tsx

console.log(numRef.current);

每次组件渲染都会执行这行打印。初始渲染输出 0,后续每次点击重渲染都会输出最新的 current 值。

第四行:点击事件逻辑

tsx

onClick={() => {
  numRef.current += 1;  // 改值,不触发渲染
  forceRender();        // 手动触发渲染
}}

这是最核心的两行,执行顺序不能颠倒:

  1. numRef.current += 1:内存中的数字加 1。但因为是 ref,React 感知不到变化,页面纹丝不动
  2. forceRender():触发组件重渲染。App 函数重新执行,JSX 重新计算,读取到最新的 numRef.current 并渲染到页面上

如果去掉 forceRender 会怎样

如果只写 numRef.current += 1 不调用强制渲染:

  • 内存里的 current 值确实在持续增长
  • 但组件永远不会重渲染
  • 页面上的数字永远停留在 0

这充分说明了一个本质:ref 只负责存值,视图更新完全依赖重渲染本身。ref 是非响应式的,修改它不会驱动视图。

五、useState vs useRef 核心区别对比

表格

特性useStateuseRef
触发重渲染修改后自动触发修改后不触发
读取时机每次渲染拿到当次快照随时读取最新值
适用场景驱动视图的业务数据DOM 引用、定时器 ID、不需要展示的中间变量
更新方式调用 set 函数直接修改 current 属性
跨渲染保留

怎么选?一个简单判断标准

这个值变化后,页面需要跟着变吗?

  • 需要 → 用 useState
  • 不需要 → 考虑 useRef

绝大多数业务场景下,需要展示给用户的数据都应该用 useStateuseRef 更多是底层工具属性的存在,比如存定时器 ID、存上一次的 props 值、存第三方库的实例等。

面试延伸:为什么 useRef 改了不触发渲染?因为 React 的重渲染调度是由 state 更新驱动的。ref 就是一个普通 JS 对象,React 没有对它做任何代理或监听,自然也感知不到变化。

六、useRef 典型使用场景总结

  1. 引用 DOM 元素:调用原生 API(focus、scroll、测量尺寸等),这是最常用的场景
  2. 存储定时器 ID:setInterval/setTimeout 的返回值,方便在卸载时清理
  3. 保存上一轮的 state/props:在 useEffect 中记录旧值,用于对比变化
  4. 存储不需要驱动视图的大数据:避免频繁 setState 造成不必要的重渲染
  5. 在闭包中获取最新值:解决 Hooks 闭包陷阱的常用手段

七、完整代码附录

示例一:DOM 自动聚焦

tsx

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;

示例二:ref 存值 + 强制渲染

tsx

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();
      }}>
        {numRef.current}
      </div>
    </>
  );
};

export default App;

useRef 的设计非常巧妙:它用最简单的对象引用机制,解决了 "在函数组件中持久保存可变值" 这个核心问题。理解了它,你就理解了 React 响应式系统的边界 —— 哪些数据该走响应式流,哪些数据该游离在外。恰当使用 useRef,能让你的组件性能更优、逻辑更清晰。