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

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

一、React 常用 Hooks 三剑客

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

useState:响应式状态

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

tsx

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

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

useEffect:副作用处理

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

tsx

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

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

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

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

第二步:JSX 中绑定 ref 属性

tsx

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

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

第三步:useEffect 中使用 DOM

tsx

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

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

ini 复制代码
const numRef = useRef(0);

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

第二行:只拿更新函数

tsx

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

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

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

第三行:打印验证时机

tsx

arduino 复制代码
console.log(numRef.current);

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

第四行:点击事件逻辑

tsx

scss 复制代码
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 核心区别对比

表格

特性 useState useRef
触发重渲染 修改后自动触发 修改后不触发
读取时机 每次渲染拿到当次快照 随时读取最新值
适用场景 驱动视图的业务数据 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

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

ini 复制代码
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,能让你的组件性能更优、逻辑更清晰。

相关推荐
用户24171401418601 小时前
一个 Tapas 菜单 App,逼我啃下了前端存储和 this 绑定两座大山
javascript
xiaominlaopodaren1 小时前
three.js地图数学基础(二):Web Mercator
javascript·gis·three.js
谷哥的小弟1 小时前
TypeScript接口
javascript·typescript
RobinDevNotes1 小时前
轻量级动画引擎Anime.js迎来V4大版本更新
开发语言·前端·javascript·ecmascript·动画·c4前端
渣波1 小时前
React 性能优化实战:从 memo 到 Hooks 的底层原理与进阶封装
前端·javascript
触底反弹1 小时前
别再 Prop Drilling 了!一文彻底搞懂 React 组件通信的 5 种方案
前端·javascript·react.js
两只羊ovo1 小时前
listToTree 速通:一维数组怎么变出多级菜单?Map 和 reduce 两种全解
前端·javascript
何时梦醒1 小时前
React 进阶必修:彻底搞懂受控组件与非受控组件
前端·javascript·react.js
今日无bug2 小时前
JS 同步与异步:从单线程到 Promise
javascript·promise