首页 / React 学习笔记 / 12

REACT · Vol.II · LESSON 12 · Hooks 精讲

useRef:访问 DOM 与「可变而不可渲染」的值

进阶#Hooks#DOM

存个值而已,为什么还需要 useRef?

第 11 篇解决了「算得贵」(useMemo)和「函数建得勤」(useCallback)两个渲染优化问题。这一篇的需求更朴素:有些值你只想默默存着,既不想让它驱动界面,也不能让它被渲染冲掉。

举个最典型的场景:输入框渲染出来后自动聚焦。想在组件里拿到 <input> 的 DOM 节点,凭直觉有两条路,都走不通:

SearchBox.tsx · ❌ 两条想当然的路
function SearchBox() {
  // ❌ 路线一:普通变量——组件函数每次渲染都从头执行(第 5 篇),
  // 随渲染生、随渲染死,存不住任何东西
  let inputEl: HTMLInputElement | null = null;

  // ❌ 路线二:useState 存 DOM 节点——能跨渲染,但 DOM 不是
  // 驱动界面的数据,塞进 state 白白多一次重渲染
  const [inputEl, setInputEl] = useState<HTMLInputElement | null>(null);
}

React 给的正解是 useRef。一句话定位:它返回一个跨渲染恒定的可变盒子——形如 { current: 初值 },从挂载到卸载始终是同一个对象;改盒子里的东西,React 全然不知,界面纹丝不动。

于是它只有两个用途,本篇各花几站讲透:

  • DOM 句柄:把盒子拴在 JSX 上,React 画完屏幕把真实节点塞进 current
  • 可变容器:存定时器 id、上一次的值、最新值镜像——各种「不参与渲染」的幕后数据。

用途一:DOM 句柄——拿到节点,随叫随到

SearchBox.tsx · ✅ 自动聚焦的正确姿势
import { useEffect, useRef } from "react";

function SearchBox() {
  // 1. 造一个盒子,初值 null(泛型标注的是 current 的类型)
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // 3. 渲染完成后,盒子里已经是真实 DOM 节点,调它的命令式 API
    inputRef.current?.focus();
  }, []);

  // 2. 把盒子拴到 JSX 上:React 提交 DOM 时把节点塞进 ref.current
  return <input ref={inputRef} placeholder="搜索文章…" />;
}

其一,渲染期间 ref.current 还是 null——节点要到提交阶段才存在,所以「动 DOM」必须放进 useEffect(第 9 篇:摸 DOM 是副作用,画完屏幕再干)。

其二,什么时候需要摸 DOM:聚焦、滚动定位、测量尺寸、播放/暂停视频——都是声明式模型覆盖不到的命令式 API 边角。表单取值仍用受控 state(第 21 篇专题):ref 是逃生舱,不是日常通道

其三,盒子恒定。组件函数换了一茬又一茬局部变量,但 inputRef 始终是同一个对象——这正是它和普通变量的分水岭。

后端类比:ref 之于 DOM,像 @Autowired 之于 Bean

Spring 容器启动时把 Bean 的引用注入到你的字段上——你拿到的是对象本体,不是快照、不是拷贝,调它任何方法都是直接操作。React 把 DOM 节点「注入」到 ref.current,一模一样。

用途二:可变容器——useState 的沉默孪生兄弟

第二个用途更能体现 useRef 的独特价值。做个秒表:「开始」开定时器,「暂停」关掉它。定时器 id 不是界面数据、要被两个事件处理器共享、还得跨渲染存活——三重约束把 useState 和局部变量全筛掉了:

Stopwatch.tsx · 定时器 id 存进 ref
import { useRef, useState } from "react";

function Stopwatch() {
  const [elapsed, setElapsed] = useState(0);      // 驱动界面:state
  const timerRef = useRef<number | null>(null);   // 幕后数据:ref

  function start() {
    if (timerRef.current !== null) return;  // 已在计时,别重复开
    timerRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
  }

  function stop() {
    if (timerRef.current === null) return;
    clearInterval(timerRef.current);
    timerRef.current = null;
  }

  return (
    <>
      <p>已计时 {elapsed} 秒</p>
      <button onClick={start}>开始</button>
      <button onClick={stop}>暂停</button>
    </>
  );
}

为什么 id 不放 state?三宗罪:不必要——id 变化不该引起重渲染;不及时——setState 要到下一轮渲染才生效,stop() 里读到的可能还是旧值;不语义——state 是「驱动界面的数据」,id 显然不是。放局部变量则每次渲染重置,start 后第一次 stop 时 id 已经丢了。ref 恰好卡在中间:跨渲染存活,又完全绕开渲染流水线

把这对孪生兄弟放一张表里看,区别一目了然:

维度useStateuseRef
改值方式必须走 setter,触发重渲染直接改 .current不触发渲染
渲染时读到的本次渲染的快照(闭包捕获,永不变).current,永远是最新值
跨渲染身份每轮渲染一份新快照同一个对象,贯穿组件一生
变化是否反映到 UI自动反映不会——所以叫「可变而不可渲染」
该装什么驱动界面的数据DOM 句柄、定时器 id、幕后标记
后端类比:实例字段 vs 每次请求新 new 的 DTO

把组件实例想成一个单例 Bean:ref.current 就是它的实例字段——谁改都生效,但框架不会因为你改了字段就重发一次请求;而 state 是被框架「审计」的属性,每次渲染像一次新的请求,你拿到的是一份不可变 DTO 快照(第 10 篇闭包陷阱的根源),想变就得走 setter。

useState:每次渲染,一份新的不可变快照 渲染 1 渲染 2 渲染 3 快照 count = 0 快照 count = 3 快照 count = 7 旧快照原封不动——闭包捕获的正是「当时那一份」(第 10 篇的病根) useRef:同一个盒子,贯穿组件一生 渲染 1 渲染 2 渲染 3 ref.current · 同一个对象,改它不触发渲染
图 1useState 快照 vs useRef 恒定引用:快照随渲染更替,ref 盒子贯穿一生

三个高频场景:上一次的值、闭包陷阱第二处方、幕后数据

场景一:usePrevious——记录上一次渲染的值

想显示「本次比上次多了几条」?在渲染里顺手存「上次」是不行的:渲染期间改任何东西都犯规(下一站细说)。正确姿势是渲染后写入

usePrevious.ts · 下一篇教你把它抽成正式的自定义 Hook
import { useEffect, useRef } from "react";

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value; // 渲染之后才写;本轮渲染读到的,正是上一轮的值
  }, [value]);

  return ref.current;
}

// 用法:const prev = usePrevious(count);「比上次多了 {count - prev} 条」

时序想清楚就不玄了:第 N 轮渲染时 effect 还没跑,ref.current 里躺着第 N-1 轮 effect 写入的值——「上一次」就是这么来的。

场景二:给第 10 篇的闭包陷阱开第二处方

第 10 篇的病根:定时器闭包捕获的是那一轮的快照。当时的处方是把 count 放进依赖数组,变了就重建定时器。ref 提供另一条路——定时器只建一次,通过恒定引用总能读到最新值

Poller.tsx · ref 镜像最新值
import { useEffect, useRef, useState } from "react";

function Poller() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);

  useEffect(() => {
    countRef.current = count; // 每次渲染后,把最新值写进镜像
  }, [count]);

  useEffect(() => {
    // 闭包捕获的是 countRef——恒定引用,不随渲染更替
    const id = setInterval(() => {
      console.log("最新 count:", countRef.current); // ✅ 永远最新
    }, 1000);
    return () => clearInterval(id);
  }, []);

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

两种处方的取舍:依赖数组版更「React 味」,但定时器反复拆建;ref 版只建一次、多养一个镜像。没有绝对优劣,看重建代价。

场景三:卸载标记等幕后数据

「不参与渲染的数据」也都归 ref 管。最典型的是卸载标记:const aliveRef = useRef(true),挂载 effect 里置 true、cleanup 里翻成 false,异步回调回来先看这面旗子再决定要不要 setOrders——cleanup 负责关资源(第 9 篇),aliveRef 负责拦「迟到的更新」。防抖句柄、最近一次请求序号,同理。

一条铁律:渲染期间别碰 ref.current

ref 的自由是「渲染之外」的自由。一旦在渲染函数体里读写 ref.current,就会捅穿第 9 篇立的规矩——渲染必须是纯函数

Badge.tsx · ❌ 渲染期间读写 ref
import { useRef } from "react";

function Badge({ items }: { items: string[] }) {
  const lastLenRef = useRef(0);

  // ❌ 渲染期间改 ref:渲染结果取决于「之前发生过什么」
  if (items.length !== lastLenRef.current) {
    lastLenRef.current = items.length;
    return <b>{items.length} 项(有更新)</b>;
  }
  return <span>{items.length} 项</span>;
}

三重罪状:其一,纯度破产——渲染结果不再只由 props 和 state 决定,还掺进了「上次渲染留下的 ref」,UI = f(state) 变成了 UI = f(state + 历史);其二,StrictMode 双调用翻车(第 9 篇末站)——第一遍渲染把 ref 改了,第二遍读到的就是被污染的值,同样的输入渲染出不同结果;其三,改了也白改——ref 变化不通知 React,渲染期写的值不会引发重渲染,数据和界面从此失同步。

正确位置只有两个:事件处理器(秒表的 start/stop)和 useEffectusePrevious 的写入);渲染函数体里只许出现初始化那一行 useRef(初值)

顺带一段历史:React 18 及之前,想把 ref 传进自定义函数组件,必须用 forwardRef 包一层才收得到;React 19 起 ref 就是普通 prop,直接写进 props 类型即可,forwardRef 光荣退休。你在旧代码里见到它,知道是时代产物就好。

MyInput.tsx · 新旧写法对照:收 ref 的姿势变了,用法没变
import { forwardRef } from "react";
import type { Ref } from "react";

// React 18 及更早:forwardRef 包一层,ref 从第二个参数进来
const MyInput = forwardRef<HTMLInputElement>(function MyInput(props, ref) {
  return <input ref={ref} placeholder={props.placeholder} />;
});

// React 19+:ref 就是普通 prop,直接写进 props 类型
function MyInput19({ ref, placeholder }: {
  ref?: Ref<HTMLInputElement>;
  placeholder?: string;
}) {
  return <input ref={ref} placeholder={placeholder} />;
}

// 两种写法,调用方完全一样:
<MyInput ref={inputRef} placeholder="搜索…" />

旧项目里见到 forwardRef 不必慌:它的语义就是「替这个组件转发 ref」,内部照样 ref={ref} 挂到真实节点上。新代码直接用 prop 写法;升级老库时也不急着改写,它不会坏,只是啰嗦。

核心要点
  • useRef(初值) 返回 { current } 恒定盒子:用途一是 DOM 句柄ref={inputRef}),用途二是跨渲染可变容器
  • 与 useState 的分水岭:改 ref 不触发渲染;渲染读到的 state 是快照,读到的 ref.current 永远是最新值。
  • 高频场景:定时器/订阅句柄、usePrevious、ref 镜像修第 10 篇闭包陷阱、卸载标记等幕后数据。
  • 铁律:渲染期间不读写 ref.current,读写放事件处理器或 effect。
  • React 19 起 ref 是普通 prop,forwardRef 成为历史名词。

本章回顾

第 11 篇管「渲染之内」的优化,本篇管「渲染之外」的持久:useRef 是组件的实例字段,装 DOM 句柄、装定时器 id、装最新值镜像,改它不惊动渲染。至此 Hooks 的三块地基——state 快照、effect 同步、ref 盒子——都已齐备。你也该发现了:ref 和 effect 总是成对出现(写镜像、清定时器、翻卸载标记),每用一次就复制粘贴一遍。下一篇第 13 篇,把它们抽成自定义 Hook——React 里最重要的复用手段。见。

Comments · 评论