告别滥用 useState:深入理解 useRef 的不可变哲学与实战避坑指南

18 阅读9分钟

在函数式组件的世界里,每一次状态的更新(State Update)都意味着组件函数的重新执行。这与类组件中实例属性(Instance Properties)持久存在的特性截然不同。

为了在函数组件中模拟“类实例属性”的能力,或者为了直接操作 DOM,React 引入了 useRef。它不仅仅是一个用来“获取 DOM 节点”的工具,更是一个跨渲染周期持久化存储数据的容器

如果你把 useState 比作家里的“智能灯光系统”(状态变了,全屋的氛围灯都要跟着变),那么 useRef 就是你家里的“记事本”(你在上面写东西,不会影响家里的灯光,但你下次回来还能看到之前记的东西)。

一、 核心概念:Ref 与 State 的本质区别

在深入代码之前,我们必须厘清 useRefuseState 的核心差异。很多初学者的错误,都源于混淆了这两个概念。

1. 响应式 vs. 普通对象

  • useState (响应式) :它是 React 的“神经中枢”。一旦你调用 setState,React 会立即安排一次重新渲染(Re-render)。组件内的所有逻辑都会重新跑一遍。
  • useRef (普通对象) :它是一个普通的 JavaScript 对象。它的 current 属性发生变化时,完全不会触发组件的重新渲染。它就像是一个被 React “遗忘”在内存角落里的盒子,只有你主动去拿东西时,它才存在。

2. 生命周期 vs. 渲染周期

  • useState:它的生命与 UI 紧密绑定。UI 变,State 变;State 变,UI 变。
  • useRef:它的生命是持久化的。从组件挂载(Mount)到卸载(Unmount),useRef 里的值始终保持同一个引用地址。你可以在任何一次渲染中修改它,它都能记住。

核心差异对比表:

特性useStateuseRef
触发渲染 (调用 setter 时) (修改 current 不触发)
数据类型任何 (原始值/对象)对象 (包含 current 属性)
主要用途管理 UI 状态存储可变值、DOM 引用
渲染影响改变值会导致组件重新渲染改变值对渲染无影响
初始化时机每次渲染都会检查初始值仅在首次渲染时创建

二、 实战演练:DOM 节点的引用与自动聚焦

useRef 最直观的用途就是获取 DOM 节点。在类组件时代,我们使用 React.createRef(),在函数组件中,我们使用 useRef

场景:输入框自动聚焦
当页面加载完成后,输入框自动获得光标。

代码实现:

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

export default function App() {
  const [count, setCount] = useState(0);
  // 1. 创建一个 ref 对象,初始值为 null
  const inputRef = useRef(null);

  // 2. 利用 useEffect 在 DOM 渲染完成后操作节点
  useEffect(() => {
    // inputRef.current 指向真实的 DOM 节点
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }, []); // 依赖为空,仅在挂载时执行

  return (
    <>
      {/* 3. 将 ref 绑定到 input 元素上 */}
      <input ref={inputRef} />
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
    </>
  );
}

代码解析:

  1. 创建 (useRef(null)) :我们在函数体内部创建了一个 inputRef 对象。此时 inputRef.current 的值是 null
  2. 绑定 (ref={inputRef}) :React 在渲染时,会将这个 inputRef 对象的 current 属性指向真实的 <input> DOM 节点。
  3. 使用 (useEffect) :在 useEffect 中,我们通过 inputRef.current 获取到了 DOM 节点,并调用了原生的 focus() 方法。

易错点 1:在渲染阶段直接操作 Ref
千万不要试图在组件函数的主体(渲染逻辑)中直接操作 ref 的值或调用 DOM 方法。

  • 错误写法

    // 错误!这会在每次渲染时尝试调用 focus,但此时 DOM 可能还没挂载
    inputRef.current?.focus(); 
    
  • 原因:组件函数的执行(JS 逻辑)早于 DOM 的渲染(浏览器绘制)。在函数体内部,inputRef.current 此时可能还是 null

易错点 2:在事件处理函数中忘记检查 null
虽然在 React 的严格模式下,挂载后 current 通常不为 null,但为了代码的健壮性,始终建议在调用 DOM 方法前进行空值检查(Nullish Check)。

💡 答疑解惑环节

Q: 为什么 useRef 可以获取到 DOM 节点,而普通对象不行?
A: 这是 React 的底层机制决定的。当你将一个 ref 对象(由 useRef 创建)传递给 JSX 元素的 ref 属性时,React 会识别这是一个特殊的“Ref 对象”,并在渲染阶段自动将该对象的 current 属性更新为对应的 DOM 节点引用。普通对象没有这个“特权”。

Q: useRef 里的值变了,为什么控制台打印的还是旧值?
A: 这是一个经典的闭包(Closure)陷阱。如果你在 useEffect 或事件处理函数中打印 ref.current,你看到的是那一刻的快照。如果你在异步操作(如 setTimeout)中打印,可能会看到旧值,除非你在异步回调中再次访问 ref.current。Ref 对象的 current 属性是可变的,但对象本身的引用是不变的。


三、 进阶应用:可变对象的存储(替代全局变量)

这是 useRef 最容易被忽视,但在复杂业务中最有用的功能。当需要在函数组件中存储一些数据,但又不希望这些数据的变化触发页面重新渲染时,useRef 是唯一的选择。

场景:计数器与定时器
我们需要一个按钮来启动和停止一个每隔 1 秒打印 "tick" 的定时器。同时,页面上还有一个独立的计数器按钮。

错误的实现(使用普通变量):

function BadExample() {
  let intervalId = null; // 普通变量
  const [count, setCount] = useState(0);

  function start() {
    // 每次 start 被调用,intervalId 都是 null
    intervalId = setInterval(() => console.log('tick'), 1000);
  }

  function stop() {
    // 这里的 intervalId 永远是 null,无法清除定时器
    clearInterval(intervalId); 
  }
  // ... JSX
}
  • 问题:每次点击计数器按钮,组件重新渲染,intervalId 变量被重新声明为 nullstop 函数永远拿不到 start 函数中设置的 ID。

正确的实现(使用 useRef):

import { useState, useRef } from 'react';

export default function App() {
  // 1. 使用 useRef 存储定时器 ID
  const intervalId = useRef(null);
  const [count, setCount] = useState(0);

  function start() {
    // 2. 修改 current 属性,不会触发渲染
    intervalId.current = setInterval(() => {
      console.log('tick~~~~');
    }, 1000);
  }

  function stop() {
    // 3. 在 stop 函数中,依然能访问到 start 存储的 ID
    if (intervalId.current) {
      clearInterval(intervalId.current);
      intervalId.current = null; // 清理
    }
  }

  return (
    <>
      <button onClick={start}>开始</button>
      <button onClick={stop}>停止</button>
      <button onClick={() => setCount(count + 1)}>
        Count: {count}
      </button>
    </>
  );
}

代码解析:

  1. 持久化存储intervalId 是一个 Ref 对象。无论组件因为 count 变化重新渲染多少次,intervalId 对象本身始终是同一个,它的 current 属性保留了上一次设置的定时器 ID。
  2. 解耦逻辑startstop 函数可以独立于 UI 状态运行。点击“Count”按钮只会增加数字,完全不会影响定时器的逻辑,因为 useRef 的变化不触发渲染。

易错点 3:误以为 useRef 是“响应式”的
很多开发者会尝试这样做:

// 错误!
const dataRef = useRef(0);
dataRef.current = 1; // 修改了值
// 期望页面自动更新?不会!

如果你希望值变了页面也变,请用 useStateuseRef 仅用于存储那些“变了也不需要 UI 刷新”的数据(如定时器 ID、上一次的 props、DOM 节点、外部库的实例等)。

💡 答疑解惑环节

Q: 既然 useRef 不触发渲染,那它和写在组件外面的全局变量有什么区别?
A: 区别巨大:

  1. 作用域隔离:全局变量是整个应用共享的。如果你有多个该组件的实例,它们会共享同一个全局变量,造成数据污染。useRef每个组件实例独有的,每个实例都有自己独立的“百宝箱”。
  2. 生命周期管理:全局变量除非手动清理,否则一直存在。useRef 随着组件的卸载,其引用的对象在没有其他引用时会被垃圾回收(GC)。

Q: useRef 的初始值是每次渲染都执行吗?
A: 不是useRef 的初始化逻辑(即传入的参数)仅在组件的首次渲染(Mount 阶段)执行一次。在后续的更新渲染中,React 会直接返回之前创建的那个 Ref 对象,忽略你传入的新值。这与 useState 的初始值不同(虽然 useState 的初始值函数也只执行一次,但这是 Hooks 的通用规则)。


四、 牛刀小试一下

1. useRefuseState 有什么区别?

  • 思考方向

    • 触发渲染useState 的更新会触发 Re-render,useRef 的更新不会。
    • 数据获取useState 读取的是当前渲染的快照,useRef 读取的是实时的最新值。
    • 引用地址useState 的对象如果替换,引用地址会变;useRef 对象本身的引用地址永远不变(变的是 current 里面的值)。
    • 使用场景:UI 状态用 State,非 UI 数据(计时器、DOM、外部实例)用 Ref。

2. 如何在 useEffect 外部访问最新的 State 值?

  • 思考方向:这是一个进阶问题。通常 useEffect 内部闭包捕获的是旧的 State。解决方案是使用 useRef 来同步状态。

    const latestCount = useRef(count);
    useEffect(() => {
      latestCount.current = count; // 每次 count 变,更新 ref
    }, [count]);
    
    // 现在在任何地方(包括 setTimeout 中),latestCount.current 都是最新值
    

    注:现代 React 更推荐使用 useEffectEvent (React 18+) 来解决此类问题,但在 18 以下版本,Ref 是标准解法。

3. 为什么有时候 useRef 打印出来的值是旧的?

  • 思考方向:这通常涉及闭包陷阱

    useEffect(() => {
      const id = setInterval(() => {
        console.log(myRef.current); // 这里是正确的,实时值
        console.log(someState);     // 这里是闭包捕获的旧值
      }, 1000);
      return () => clearInterval(id);
    }, []); // 注意依赖项为空
    

    如果你在 setInterval 回调中直接使用 someState,它会永远是组件挂载时的值。解决办法是:要么把 someState 放进依赖项(导致定时器频繁重置),要么使用 useRef 来存储 someState 并在 useEffect 中同步它,或者使用函数式更新。

4. useRef 可以用来做性能优化吗?

  • 思考方向:可以。

    • 避免对象/函数重建:在 useMemouseCallback 无法满足需求时,可以用 useRef 存储复杂的计算结果或对象实例,避免在每次渲染时都重新创建,从而减少子组件的不必要渲染。
    • 避免 Effect 重复执行:如果 useEffect 的依赖项是一个对象,对象属性的改变会导致 Effect 重新执行。如果用 useRef 存储这个对象并手动管理变化,可以控制 Effect 的执行时机。

通过这篇博客,希望你不再把 useRef 仅仅看作是操作 DOM 的工具,而是将其视为 React 函数组件中维持状态与逻辑分离的瑞士军刀