REACT · Vol.II · LESSON 12 · Hooks 精讲
useRef:访问 DOM 与「可变而不可渲染」的值
存个值而已,为什么还需要 useRef?
第 11 篇解决了「算得贵」(useMemo)和「函数建得勤」(useCallback)两个渲染优化问题。这一篇的需求更朴素:有些值你只想默默存着,既不想让它驱动界面,也不能让它被渲染冲掉。
举个最典型的场景:输入框渲染出来后自动聚焦。想在组件里拿到 <input> 的 DOM 节点,凭直觉有两条路,都走不通:
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 句柄——拿到节点,随叫随到
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 始终是同一个对象——这正是它和普通变量的分水岭。
Spring 容器启动时把 Bean 的引用注入到你的字段上——你拿到的是对象本体,不是快照、不是拷贝,调它任何方法都是直接操作。React 把 DOM 节点「注入」到 ref.current,一模一样。
用途二:可变容器——useState 的沉默孪生兄弟
第二个用途更能体现 useRef 的独特价值。做个秒表:「开始」开定时器,「暂停」关掉它。定时器 id 不是界面数据、要被两个事件处理器共享、还得跨渲染存活——三重约束把 useState 和局部变量全筛掉了:
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 恰好卡在中间:跨渲染存活,又完全绕开渲染流水线。
把这对孪生兄弟放一张表里看,区别一目了然:
| 维度 | useState | useRef |
|---|---|---|
| 改值方式 | 必须走 setter,触发重渲染 | 直接改 .current,不触发渲染 |
| 渲染时读到的 | 本次渲染的快照(闭包捕获,永不变) | .current,永远是最新值 |
| 跨渲染身份 | 每轮渲染一份新快照 | 同一个对象,贯穿组件一生 |
| 变化是否反映到 UI | 自动反映 | 不会——所以叫「可变而不可渲染」 |
| 该装什么 | 驱动界面的数据 | DOM 句柄、定时器 id、幕后标记 |
把组件实例想成一个单例 Bean:ref.current 就是它的实例字段——谁改都生效,但框架不会因为你改了字段就重发一次请求;而 state 是被框架「审计」的属性,每次渲染像一次新的请求,你拿到的是一份不可变 DTO 快照(第 10 篇闭包陷阱的根源),想变就得走 setter。
三个高频场景:上一次的值、闭包陷阱第二处方、幕后数据
场景一:usePrevious——记录上一次渲染的值
想显示「本次比上次多了几条」?在渲染里顺手存「上次」是不行的:渲染期间改任何东西都犯规(下一站细说)。正确姿势是渲染后写入:
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 提供另一条路——定时器只建一次,通过恒定引用总能读到最新值:
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 篇立的规矩——渲染必须是纯函数:
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)和 useEffect(usePrevious 的写入);渲染函数体里只许出现初始化那一行 useRef(初值)。
顺带一段历史:React 18 及之前,想把 ref 传进自定义函数组件,必须用 forwardRef 包一层才收得到;React 19 起 ref 就是普通 prop,直接写进 props 类型即可,forwardRef 光荣退休。你在旧代码里见到它,知道是时代产物就好。
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 · 评论