React 进阶:useRef —— 那个“只做事不说话”的幕后英雄

0 阅读4分钟

在 React 的学习过程中,你一定遇到过这种困境:

  • 我想操作 DOM(比如让输入框自动聚焦),但 React 告诉我不要直接操作 DOM。
  • 我想保存一个变量,不希望它重置,但也不希望它的改变触发组件重新渲染。
  • 我在 useEffect 里怎么都拿不到最新的 State 值(闭包陷阱)。

这时候,你需要的就是 useRef

一、 什么是 useRef?

简单来说,useRef 创建了一个普通的 JavaScript 对象,它长这样:

{ 
  current: ... // 这里存着你的值
}

它有两个核心特性,必须死记硬背:

  1. 引用透传:在组件的整个生命周期内,这个对象永远是同一个(引用地址不变)。
  2. 变更不渲染:修改 ref.current 的值,不会触发组件重新渲染(这与 useState 完全相反)。

打个比方:

  • useState 像是橱窗里的模特。换了衣服(状态改变),大家都能看见(页面重绘)。
  • useRef 像是你口袋里的记事本。你写了什么(修改值),只有你自己知道,外面的人看不见(页面会重绘)。

二、 场景一:访问 DOM 节点(最常见用法)

React 是声明式的,我们通常不需要直接碰触 DOM。但在某些场景下(管理焦点、文本选择、媒体播放、强制动画等),我们需要“逃生舱”。

import { useRef, useEffect } from 'react';

export default function TextInputWithFocusButton() {
  // 1. 创建一个 ref,初始值为 null
  const inputEl = useRef(null);

  const onButtonClick = () => {
    // 3. 通过 .current 访问真实的 DOM 节点
    // 注意:React 会在组件挂载后,自动把 DOM 赋给 current
    inputEl.current.focus();
    console.log('输入框现在的宽度是:', inputEl.current.offsetWidth);
  };

  return (
    <div>
      {/* 2. 把 ref 绑定到 JSX 元素上 */}
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>点我聚焦输入框</button>
    </div>
  );
}

注意: 不要过度使用 Ref 操作 DOM。如果你发现自己在用 Ref 去修改 DOM 的内容(如 inputEl.current.value = 'hello'),这通常意味着你写错了,应该用 useState 控制。

三、 场景二:存储“幕后”变量(解决闭包陷阱)

还记得上一篇文章里的“闭包陷阱”吗?定时器里永远只能读到旧的 count。

除了把 count 加入依赖项,useRef 提供了一种更巧妙的解法:“逃课”大法。

既然闭包锁住的是变量的引用,那我们就创建一个永远不变的容器(Ref) ,把最新的值随时放进去。

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

export default function Timer() {
  const [count, setCount] = useState(0);
  
  // 1. 创建一个 Ref 用来“偷运”最新的 count
  const latestCountRef = useRef(count);

  // 2. 每次渲染,都把最新的 count 写入 Ref
  // 这不会触发重绘,因为修改 Ref 是副作用
  latestCountRef.current = count;

  useEffect(() => {
    const timer = setInterval(() => {
      // 3. 定时器里不读 State,而是读 Ref
      // 因为 Ref 对象的引用地址从未改变,所以闭包能一直访问到它
      // 而 .current 属性总是被我们要么更新为最新的
      console.log('定时器读取到的最新值:', latestCountRef.current);
    }, 1000);

    return () => clearInterval(timer);
  }, []); // ✅ 依赖项可以为空!因为 Ref 对象本身是稳定的

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

原理解析:

  • count 是每次渲染都不同的数字(值类型)。闭包一旦形成,捕获的是当年的那个数字。
  • latestCountRef 是一个对象(引用类型)。闭包捕获的是这个对象的地址。哪怕里面的 .current 变了,只要地址没变,闭包就能顺藤摸瓜找到最新的值。

四、 场景三:记录“上一次”的值

有时候我们需要知道状态“上一次”是什么,比如判断股票是涨了还是跌了。React 没有提供 usePrevious 这样的 Hook,我们可以用 useRef 自己造一个。

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

function Counter() {
  const [count, setCount] = useState(0);
  
  // 用于存储上一次的值
  const prevCountRef = useRef();

  useEffect(() => {
    // 渲染完成后,更新 ref
    // 只有在下一次渲染时,我们才能拿出来对比
    prevCountRef.current = count;
  }); // 每次渲染后都执行

  // 在本次渲染中,prevCountRef.current 还是旧值
  // 因为 useEffect 是在渲染结构提交到屏幕**之后**才运行的
  const prevCount = prevCountRef.current;

  return (
    <div>
      <h1>当前: {count}</h1>
      <h2>上一次: {prevCount}</h2>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

五、 灵魂拷问:为什么不用普通变量?

新手常问: “为什么不用 let variable = 0 定义在组件外面或者里面,非要用 useRef?”

1. 为什么不能定义在组件里面

function App() {
  let timerId = 0; // ❌ 错误
  // ...
}

原因: 组件每次重新渲染,函数就会重新执行。timerId 会被重置为 0。你辛辛苦苦存的数据瞬间丢失。

2. 为什么不能定义在组件外面

let timerId = 0; // ❌ 错误(除非是单例)
function App() {
  // ...
}

原因: 如果你的页面上有 5 个 组件,它们会共享同一个全局的 timerId。一个组件改了,别的组件全乱套了。

useRef 保证了数据是“也就是组件实例独享的”,且“穿越渲染周期而不丢失”。

总结:useRef vs useState

特性useStateuseRef
主要用途存储直接影响视图的数据存储 DOM 引用、定时器 ID、无关视图的数据
数据变化时触发组件重新渲染不触发重新渲染
读取时机渲染过程中直接读取通常在 useEffect 或事件处理函数中读取
心智模型组件的状态(State)组件的实例变量(Instance Variable)

当你下一次想在 React 里存点东西,但又不想因为它变了而导致页面莫名其妙闪烁(重绘)时,请立刻想起 useRef