React 基础体系 · 第 7/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Ref 与 DOM:useRef、forwardRef、测量、焦点和命令式边界
在 React 中,组件通常通过 props 向下传递数据,通过 事件回调向上报告意图。ref 属于另一条通道:它允许代码取得某个已提交到页面的 DOM 节点,或取得子组件主动暴露的命令式 API。
这条通道很有用,但也更容易绕过 React 的声明式数据流。理解 ref 的关键,不是记住“如何拿到 DOM”,而是明确:
ref何时可用,何时会被设为null;useRef保存的值为什么变化却不会触发渲染;- React 19 中函数组件如何接收
ref; - 测量和聚焦为什么通常需要 Effect;
forwardRef和useImperativeHandle如何形成明确的命令式边界;- 在并发渲染、严格模式、SSR 和动态 DOM 中,哪些假设不成立。
一、ref 解决的是什么问题
1. 声明式状态与命令式对象
声明式代码描述“页面应该是什么样子”:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
这里的因果关系是:
count = 0
↓ setCount(1)
React 重新渲染
↓
button 文本变为 1
而聚焦、播放视频、读取布局等操作不是“声明一个值”就能完全完成的。例如:
inputElement.focus();
videoElement.play();
const rect = element.getBoundingClientRect();
这些 API 作用于浏览器已经创建的对象,属于命令式操作。ref 是 React 提供的入口,使组件能够在提交完成后访问这些对象。
2. ref 的三个基本对象类型
在 React 中,ref 最常见地指向三类对象:
| 目标 | ref.current 的值 |
|---|---|
| 原生 DOM 元素 | HTMLInputElement、HTMLDivElement 等 |
| 类组件实例 | 旧式类组件实例 |
| 函数组件暴露的命令式句柄 | 由 useImperativeHandle 返回的对象 |
函数组件本身没有实例,因此不能直接把普通函数组件当成 DOM 节点来访问。它必须显式接收并处理 ref,或者暴露一个句柄。
二、useRef:一个跨渲染保持的可变容器
1. 基本语义
import { useRef } from "react";
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
return <input ref={inputRef} />;
}
useRef(initialValue) 返回一个稳定的对象:
{
current: initialValue
}
在这个组件实例的整个生命周期内,React 会重复返回同一个 ref 对象。渲染之间变化的是 current 的内容,而不是 ref 对象本身。
可以把它形式化为:
R = useRef(initialValue)
第一次渲染:R = { current: initialValue }
后续渲染:R === 上一次的 R
但是:
R.current = newValue
不会自动触发重新渲染。因为 React 监听的是 setState 等更新机制,而不是普通 JavaScript 对象属性的赋值。
2. DOM ref 的生命周期
下面的组件中,React 会在提交阶段维护 inputRef.current:
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
function focusInput() {
inputRef.current?.focus();
}
return (
<>
<input ref={inputRef} />
<button type="button" onClick={focusInput}>
聚焦
</button>
</>
);
}
其生命周期大致是:
渲染阶段:
inputRef.current 可能仍为 null
↓
React 提交 <input> 到 DOM
↓
React 设置 inputRef.current = HTMLInputElement
↓
用户点击按钮
↓
focusInput 读取 current 并调用 focus()
↓
组件卸载或节点被替换
↓
React 设置 inputRef.current = null
因此,以下代码是不可靠的:
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
// ❌ 渲染时不能假设 DOM 已经存在
inputRef.current?.focus();
return <input ref={inputRef} />;
}
渲染函数必须尽量是纯函数。渲染期间可能发生预览、重试、放弃或重新开始;真正的 DOM 操作应放在事件处理函数、Effect 或 ref 回调中。
3. useRef 与 state 的区别
function Example() {
const countRef = useRef(0);
const [count, setCount] = useState(0);
function updateBoth() {
countRef.current += 1;
setCount((value) => value + 1);
}
return (
<button onClick={updateBoth}>
state: {count}, ref: {countRef.current}
</button>
);
}
点击一次后,setCount 触发重新渲染,所以页面通常会看到:
state: 1, ref: 1
但如果只有:
countRef.current += 1;
React 不会重新执行组件函数,页面上的文本也不会更新。ref 适合保存“需要跨渲染存在,但变化本身不决定 UI”的值,例如:
- DOM 节点;
- 定时器 ID;
- 上一次的值;
- 外部库实例;
- 不希望触发渲染的可变缓存。
如果一个值变化后 UI 必须变化,它应该是 state 或其他响应式数据,而不是 ref。
4. useRef 初始值只在首次渲染使用
const valueRef = useRef(createExpensiveObject());
createExpensiveObject() 这个表达式会在每次组件函数执行时被求值,但 React 只使用第一次得到的值作为 ref 初始值。若创建过程昂贵,不应误以为 useRef 会自动阻止表达式执行。
一种显式的惰性初始化方式是:
class VideoPlayer {
play() {
// ...
}
}
function PlayerController() {
const playerRef = useRef<VideoPlayer | null>(null);
if (playerRef.current === null) {
playerRef.current = new VideoPlayer();
}
return <button onClick={() => playerRef.current?.play()}>播放</button>;
}
这种初始化必须满足一个重要条件:同样的输入总是产生可预测的同一个初始化结果,并且不会在渲染过程中执行有外部副作用的操作。创建 WebSocket、订阅、启动计时器等不应这样做,应放入 Effect 并清理。
三、把 ref 传给 DOM:对象 ref、回调 ref 和函数组件
1. 对象 ref
对象 ref 是 useRef 返回的对象:
function TextField() {
const ref = useRef<HTMLInputElement>(null);
return <input ref={ref} />;
}
React 在提交时写入:
ref.current = inputElement;
卸载或解除绑定时写入:
ref.current = null;
2. 回调 ref
ref 也可以是函数:
function TextField() {
const handleRef = (node: HTMLInputElement | null) => {
if (node) {
node.focus();
}
};
return <input ref={handleRef} />;
}
回调 ref 在节点建立或解除绑定时接收:
node 建立绑定
null 解除绑定
回调 ref 适合需要“节点一出现就执行初始化”的场景,或者需要将一个节点同时交给多个系统的场景。
但不要在组件内无条件创建复杂回调并把它当成稳定订阅机制。回调函数身份变化可能导致 React 先调用旧回调并传入 null,再调用新回调传入节点。可以使用 useCallback 保持身份稳定:
const handleRef = useCallback((node: HTMLDivElement | null) => {
if (node) {
// 初始化第三方库
} else {
// 清理
}
}, []);
React 19 支持回调 ref 返回清理函数;但若组件需要兼容较早的 React 版本,使用 null 分支进行清理更稳妥。
3. 同一个节点合并多个 ref
有时组件既要维护自己的 ref,又要把节点交给调用方:
import { useCallback, useRef } from "react";
function useMergedRefs<T>(
...refs: Array<React.Ref<T> | undefined>
) {
return useCallback((node: T | null) => {
for (const ref of refs) {
if (!ref) continue;
if (typeof ref === "function") {
ref(node);
} else {
ref.current = node;
}
}
}, refs);
}
使用:
function Input(props: React.ComponentProps<"input">) {
const internalRef = useRef<HTMLInputElement>(null);
const ref = useMergedRefs(internalRef, props.ref);
return <input {...props} ref={ref} />;
}
这个示例的关键是:外部 ref 可能是对象,也可能是函数,不能只处理 MutableRefObject。在库代码中还应根据目标 React 版本和 TypeScript 类型定义验证 props.ref 的类型。
四、React 19 中的 ref 传递与 forwardRef
1. React 19 的首选写法:把 ref 当作 prop
在 React 19 中,函数组件可以直接声明 ref prop:
type TextFieldProps = {
label: string;
ref?: React.Ref<HTMLInputElement>;
};
function TextField({ label, ref }: TextFieldProps) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
调用方:
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <TextField label="邮箱" ref={inputRef} />;
}
React 19 文档将 forwardRef 标记为将被弃用的 API,并推荐直接使用 ref prop。这里的“推荐”不等于旧代码立刻失效:现有 forwardRef 仍是兼容和维护旧版本组件的重要方式。
实际项目中应区分:
- 目标只支持 React 19:优先直接声明
refprop; - 组件库需要兼容 React 18 或更早版本:继续使用
forwardRef; - 升级中的代码库:不要为了形式统一而一次性改动所有 ref,先以兼容性和类型检查为准。
2. forwardRef 的工作方式
React 18 及之前,ref 不会像普通 prop 一样自动进入函数组件,因此需要:
import { forwardRef } from "react";
type TextFieldProps = {
label: string;
};
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
function TextField({ label }, ref) {
return (
<label>
{label}
<input ref={ref} />
</label>
);
}
);
forwardRef(render) 的作用是把外部传入的 ref 作为第二个参数交给内部渲染函数:
<TextField ref={outerRef} />
↓
forwardRef render(props, ref)
↓
<input ref={ref} />
如果忘记透传:
const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
function TextField({ label }, _ref) {
return (
<label>
{label}
<input /> {/* ref 被丢弃 */}
</label>
);
}
);
调用方的 outerRef.current 会一直是 null。这不是 React 找不到 DOM,而是组件没有建立 ref 的传递链。
五、测量 DOM:何时测量、测量什么、如何避免错误读数
1. DOM 测量依赖“已提交的布局”
测量通常使用:
const rect = element.getBoundingClientRect();
返回值包含:
{
x, y, top, right, bottom, left,
width, height
}
这些值描述元素相对于视口的几何信息。width 和 height 可能包含边框盒尺寸;它们还会受到 CSS transform、滚动位置、缩放等因素影响,不能简单等同于 CSS width 和 height。
如果只想取得布局尺寸,还可能使用:
element.offsetWidth
element.offsetHeight
或:
element.clientWidth
element.clientHeight
它们对边框、滚动条和小数像素的处理不同。测量前应先明确问题是:
- 需要元素在视口中的位置:
getBoundingClientRect(); - 需要布局占用的整数尺寸:
offsetWidth/offsetHeight; - 需要内容区域尺寸:
clientWidth/clientHeight; - 需要监听尺寸变化:
ResizeObserver。
2. useEffect 与 useLayoutEffect
React 的提交过程可以简化为:
渲染
↓
提交 DOM 更新
↓
浏览器布局和绘制
↓
useEffect
useLayoutEffect 的位置更接近:
渲染
↓
提交 DOM 更新
↓
useLayoutEffect
↓
浏览器绘制
当测量结果会立刻影响首次可见布局时,useLayoutEffect 可以避免用户看到“先显示错误位置,再跳到正确位置”的闪烁:
import { useLayoutEffect, useRef, useState } from "react";
function MeasuredPanel() {
const panelRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | null>(null);
useLayoutEffect(() => {
const node = panelRef.current;
if (!node) return;
const rect = node.getBoundingClientRect();
setHeight(rect.height);
}, []);
return (
<section>
<div ref={panelRef}>需要测量的内容</div>
<p>高度:{height === null ? "测量中" : `${height}px`}</p>
</section>
);
}
这里的步骤是:
- 首次渲染产生
section和div; - React 提交 DOM;
useLayoutEffect读取已存在的div;- 得到高度后设置 state;
- React 再渲染高度文本;
- 浏览器绘制最终结果。
如果测量只用于日志、统计或非视觉逻辑,普通 useEffect 通常足够。useLayoutEffect 会阻塞浏览器绘制,不能因为“测量都更准确”就默认使用它。
3. 处理动态尺寸:ResizeObserver
只在挂载时测量一次不能覆盖以下变化:
- 文本换行;
- 字体加载完成;
- 容器宽度改变;
- 响应式布局变化;
- 子元素异步加载;
- 用户改变窗口大小。
可以用 ResizeObserver 监听元素尺寸:
import {
useLayoutEffect,
useRef,
useState,
} from "react";
type Size = {
width: number;
height: number;
};
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<Size | null>(null);
useLayoutEffect(() => {
const node = ref.current;
if (!node) return;
const update = (width: number, height: number) => {
setSize((previous) => {
if (
previous &&
previous.width === width &&
previous.height === height
) {
return previous;
}
return { width, height };
});
};
const firstRect = node.getBoundingClientRect();
update(firstRect.width, firstRect.height);
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const box = entry.borderBoxSize?.[0];
if (box) {
update(box.inlineSize, box.blockSize);
} else {
update(entry.contentRect.width, entry.contentRect.height);
}
});
observer.observe(node);
return () => {
observer.disconnect();
};
}, []);
return [ref, size] as const;
}
function ResizableInfo() {
const [panelRef, size] = useElementSize<HTMLDivElement>();
return (
<div ref={panelRef} style={{ resize: "both", overflow: "auto" }}>
<p>
{size
? `${Math.round(size.width)} × ${Math.round(size.height)}`
: "测量中"}
</p>
</div>
);
}
输入是一个可改变尺寸的 DOM 元素,预期结果是元素大小变化时文本同步更新。清理函数调用 disconnect(),避免组件卸载后观察器继续持有节点引用或更新已卸载组件。
需要注意,ResizeObserver 观察的是尺寸变化,不是位置变化。元素因滚动、定位或 transform 改变位置而没有改变尺寸时,它不会触发;这类需求可能需要滚动监听、IntersectionObserver 或重新测量策略。
4. 测量的失败路径
以下情况会导致读数与直觉不符:
const rect = node.getBoundingClientRect();
- 节点还没有提交:
ref.current === null; - 节点处于
display: none的子树:尺寸通常为零; - 字体尚未加载:文本最终换行后尺寸会改变;
- 使用 CSS transform:视觉尺寸和布局尺寸可能不同;
- 页面或祖先发生缩放:像素值不再代表未经缩放的 CSS 设计尺寸;
- 测量后立刻写样式,再次测量:可能产生布局读写交错和性能问题。
基本的读写顺序应尽量保持:
先集中读取布局
↓
再集中写入样式或 state
不要在大量节点上交替执行:
读 A → 写 A → 读 B → 写 B
这可能迫使浏览器反复计算布局。具体性能取决于 DOM 规模和样式复杂度,不能用固定数字保证结果。
六、焦点管理:ref 只是入口,可访问性才是完整目标
1. 基本聚焦
function SearchForm() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<form
onSubmit={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
>
<label htmlFor="query">搜索</label>
<input id="query" ref={inputRef} />
<button type="submit">提交</button>
</form>
);
}
这里使用了三项不同机制:
ref取得输入框;focus()执行浏览器命令;label与htmlFor建立可访问名称。
只有调用 focus() 而没有语义标签,并不能构成完整的可访问交互。
2. 在内容出现后聚焦
import { useEffect, useRef } from "react";
function SearchDialog({ open }: { open: boolean }) {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
}, [open]);
if (!open) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">搜索</h2>
<input ref={inputRef} aria-label="搜索关键词" />
</div>
);
}
依赖数组中的 open 表示:当对话框从关闭变为打开时,执行聚焦逻辑。不能在渲染阶段调用 focus(),因为此时节点可能尚未提交。
如果对话框关闭时需要把焦点还给触发按钮,还要保存触发元素:
function DialogTrigger() {
const triggerRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
function close() {
setOpen(false);
triggerRef.current?.focus();
}
return (
<>
<button ref={triggerRef} onClick={() => setOpen(true)}>
打开对话框
</button>
{open && (
<div role="dialog" aria-modal="true">
<button onClick={close}>关闭</button>
</div>
)}
</>
);
}
真实对话框还需要处理 Escape、焦点陷阱或采用成熟的对话框组件。role="dialog" 和 aria-modal="true" 不会自动实现焦点管理;Portal 也不会自动实现这些行为。
3. 动态列表中的焦点
列表 ref 的常见错误是按数组下标保存:
// ❌ 删除或排序后,下标可能对应错误节点
const refs = items.map(() => useRef<HTMLButtonElement>(null));
Hooks 不能在循环中动态调用,而且下标与数据身份不是同一个概念。应使用稳定 key 和 Map:
function ActionList({ ids }: { ids: string[] }) {
const refs = useRef(new Map<string, HTMLButtonElement>());
function focusItem(id: string) {
refs.current.get(id)?.focus();
}
return (
<div>
{ids.map((id) => (
<button
key={id}
ref={(node) => {
if (node) {
refs.current.set(id, node);
} else {
refs.current.delete(id);
}
}}
>
项目 {id}
</button>
))}
<button onClick={() => focusItem(ids[0] ?? "")}>
聚焦第一项
</button>
</div>
);
}
这里必须同时清理 null,否则节点卸载后 Map 仍可能保存旧 DOM 引用。key 也必须代表数据身份;如果使用数组下标,排序和插入会导致 React 复用错误节点,焦点和输入状态可能看起来“跳错位置”。
七、命令式边界:暴露能力,而不是暴露内部结构
1. 为什么不能让父组件随意修改子组件 DOM
下面这种 API 暴露了过多内部细节:
function Parent() {
const inputRef = useRef<HTMLInputElement>(null);
return <InternalTextField ref={inputRef} />;
}
如果 InternalTextField 未来从 <input> 改成包含遮罩、隐藏输入和按钮的复杂结构,父组件就会依赖内部 DOM 类型。组件内部实现和外部调用方发生了不必要的耦合。
更稳定的做法是只暴露少量命令:
type TextFieldHandle = {
focus: () => void;
clear: () => void;
};
父组件关心“能做什么”,而不是“内部有哪个节点”。
2. useImperativeHandle
React 提供 useImperativeHandle(ref, createHandle, dependencies) 定义 ref 对外看到的值:
import {
forwardRef,
useImperativeHandle,
useRef,
} from "react";
export type TextFieldHandle = {
focus: () => void;
clear: () => void;
};
type TextFieldProps = {
label: string;
};
export const TextField = forwardRef<
TextFieldHandle,
TextFieldProps
>(function TextField({ label }, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus() {
inputRef.current?.focus();
},
clear() {
if (inputRef.current) {
inputRef.current.value = "";
inputRef.current.dispatchEvent(
new Event("input", { bubbles: true })
);
}
},
}),
[]
);
return (
<label>
{label}
<input ref={inputRef} />
</label>
);
});
调用方:
function Form() {
const fieldRef = useRef<TextFieldHandle>(null);
return (
<>
<TextField ref={fieldRef} label="用户名" />
<button onClick={() => fieldRef.current?.focus()}>
聚焦
</button>
<button onClick={() => fieldRef.current?.clear()}>
清空
</button>
</>
);
}
调用链为:
Form
↓ 调用 fieldRef.current.focus()
TextFieldHandle.focus()
↓
inputRef.current.focus()
↓
浏览器将焦点交给内部 input
3. useImperativeHandle 的依赖关系
createHandle 会读取组件作用域中的值,因此这些值必须出现在依赖数组中:
useImperativeHandle(
ref,
() => ({
focus() {
inputRef.current?.focus();
console.log(label);
},
}),
[label]
);
如果 label 改变而依赖数组仍为 [],句柄中的闭包可能继续读取旧值。inputRef 对象本身在组件生命周期内稳定,因此不需要因为 inputRef.current 改变而重新创建句柄;current 的变化由方法调用时读取。
命令式句柄的方法应当防御节点不存在:
focus() {
inputRef.current?.focus();
}
组件可能刚刚卸载、条件渲染切换中,或调用方持有的是旧句柄。句柄不能假设 DOM 永远存在。
4. React 19 直接接收 ref 的句柄写法
在 React 19 中可以不使用 forwardRef:
type TextFieldProps = {
label: string;
ref?: React.Ref<TextFieldHandle>;
};
function TextField({ label, ref }: TextFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) {
inputRef.current.value = "";
}
},
}),
[]
);
return (
<label>
{label}
<input ref={inputRef} />
</label>
);
}
如果 clear() 需要与 React 控制的 value 同步,就不能只修改 DOM:
// ❌ 受控输入的 value 仍由 React state 决定
inputRef.current.value = "";
对于受控组件,应把清空定义成声明式动作,例如由父组件提供 onClear,或者让父组件修改 value。命令式 DOM 修改只适合组件确实以非受控方式管理该值的情况。
八、ref 与 Effect:时序、依赖和清理
1. Ref 不是 Effect 的替代品
下面代码把外部系统初始化放在渲染中:
function MapView() {
const containerRef = useRef<HTMLDivElement>(null);
// ❌ 渲染期间创建外部实例
// const map = new MapLibrary(containerRef.current);
return <div ref={containerRef} />;
}
DOM 节点尚未可靠提交,且渲染可能被 React 放弃。应在 Effect 中同步:
function MapView({ zoom }: { zoom: number }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<MapInstance | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const map = new MapInstance(container, { zoom });
mapRef.current = map;
return () => {
map.destroy();
mapRef.current = null;
};
}, [zoom]);
return <div ref={containerRef} />;
}
这里有两个独立生命周期:
containerRef.current由 React 维护;mapRef.current由 Effect 中的外部库实例维护。
清理函数必须撤销初始化产生的资源。若 zoom 变化会使实例配置失效,就需要销毁旧实例并创建新实例;如果外部库支持原地更新,也可以只调用 map.setZoom(zoom),此时应把更新逻辑和初始化逻辑分开。
2. Strict Mode 下的重复初始化暴露问题
开发环境的 Strict Mode 可能让 Effect 经历:
setup → cleanup → setup
这不是生产环境一定重复执行的承诺,而是帮助发现 Effect 不可逆的问题。若代码这样写:
useEffect(() => {
subscribe();
}, []);
却没有取消订阅,开发环境中会看到重复监听、重复事件或重复网络行为。正确结构是:
useEffect(() => {
const unsubscribe = subscribe();
return () => {
unsubscribe();
};
}, []);
不要用 ref 设置“已经运行过”的标志来绕过清理:
// ❌ 掩盖生命周期错误
const ran = useRef(false);
useEffect(() => {
if (ran.current) return;
ran.current = true;
subscribe();
}, []);
这会让开发环境看似不重复,但并没有解决资源释放问题。
3. 用 ref 读取最新值:解决闭包问题,但不触发响应
某些事件监听器只安装一次,却需要读取最新配置,可以将最新值写入 ref:
function KeyboardListener({ enabled }: { enabled: boolean }) {
const enabledRef = useRef(enabled);
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (enabledRef.current && event.key === "Escape") {
console.log("处理 Escape");
}
}
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, []);
return null;
}
这里的取舍是:
- 监听器不因
enabled改变而反复安装; - 处理函数读取的是最新的
enabledRef.current; - 修改 ref 不会渲染 UI。
如果监听器本来就应该随着依赖变化而重建,直接把 enabled 放进 Effect 依赖更清楚。ref 不是自动解决依赖问题的工具。
九、并发渲染下不能读取“尚未提交”的 ref
React 可以先渲染一个新树,再决定是否提交它。渲染期间写入或读取 ref 可能产生错误假设:
function Component({ value }: { value: string }) {
const previous = useRef(value);
// ⚠️ 渲染期间修改可变对象
previous.current = value;
return <span>{previous.current}</span>;
}
这个例子虽然看起来简单,但“渲染阶段修改 ref”会让可变对象暴露给其他逻辑,破坏渲染纯度。保存上一次已提交值时,更常见的写法是:
function PreviousValue({ value }: { value: string }) {
const previousRef = useRef<string | undefined>(undefined);
useEffect(() => {
previousRef.current = value;
}, [value]);
return (
<p>
当前值:{value};上一次值:
{previousRef.current ?? "无"}
</p>
);
}
Effect 在提交后更新,因此下一次渲染读取到的是上一次已提交值,而不是某次可能被放弃的渲染值。
需要区分一个常见例外:在渲染期间进行一次性、可预测的 ref 初始化,有时是允许的;但不能借此在渲染阶段读取 DOM、订阅外部资源或执行不可逆副作用。
十、服务端渲染与客户端边界
DOM 只存在于浏览器。服务端渲染时:
const ref = useRef<HTMLDivElement>(null);
可以创建 ref 容器,但服务端不会有真实的 HTMLDivElement,也不会执行浏览器的 focus()、测量或 ResizeObserver。
因此,以下代码必须位于客户端组件或浏览器执行路径:
useEffect(() => {
const node = ref.current;
if (!node) return;
node.focus();
}, []);
在支持 Server Components 的框架中,包含 useRef、事件处理和 DOM 操作的组件通常需要客户端边界,例如:
"use client";
import { useRef } from "react";
具体边界语法由框架决定,但原则不变:
服务端:
生成 HTML 和可序列化数据
↓
浏览器加载并 hydration
↓
客户端 React 创建 ref、绑定事件、执行 Effect
↓
DOM 操作才可发生
不要在模块顶层直接访问:
// ❌ 服务端导入模块时 window 可能不存在
const width = window.innerWidth;
也不要把 HTMLElement、函数、DOM 节点等不可序列化对象作为服务端到客户端的普通 props 传递。ref 应在客户端组件内部建立,并通过客户端事件或 Effect 使用。
useLayoutEffect 在服务端没有布局可测量。不同框架对服务端警告的处理不同;如果组件确实依赖布局测量,应将其放在客户端边界内,而不是试图在服务端模拟 DOM 尺寸。
十一、常见误解与诊断路径
1. ref.current 为什么是 null
依次检查:
- ref 是否真正挂在 JSX 节点上;
- 组件是否仍处于条件渲染的关闭分支;
- 读取是否发生在渲染期间,而不是事件或 Effect 中;
- 自定义组件是否透传了 ref;
- 是否在节点卸载之后调用;
- 是否把对象 ref 和回调 ref 的类型混用了。
错误:
function Wrapper({ ref }: { ref: React.Ref<HTMLInputElement> }) {
return <div ref={ref} />; // 类型或语义都不匹配
}
正确做法是让 ref 类型与最终节点一致,或暴露明确句柄。
2. ref 改了但页面没有更新
这是预期行为:
ref.current = value;
如果页面需要显示 value,必须使用 state:
const [value, setValue] = useState("");
ref 只能作为非响应式存储。
3. 一个 ref 被多个节点使用
function Wrong() {
const ref = useRef<HTMLDivElement>(null);
return (
<>
<div ref={ref}>A</div>
<div ref={ref}>B</div>
</>
);
}
同一个 ref 不能表达“两个当前节点”。最终 current 会指向后续绑定的节点,卸载顺序还可能使其变为 null。应使用多个 ref 或 Map。
4. 列表中的节点错位
若列表使用不稳定 key:
items.map((item, index) => (
<input key={index} />
))
插入、删除、排序后,React 可能复用原节点。结果可能表现为:
- 焦点跑到错误项目;
- 非受控输入保留了错误文本;
- ref Map 与屏幕上的数据不一致。
key 应稳定对应数据身份:
items.map((item) => (
<input key={item.id} />
))
5. 测量结果总是零
先检查:
const node = ref.current;
console.log(node);
console.log(node?.getBoundingClientRect());
若节点存在但宽高为零,再检查:
- 祖先是否
display: none; - 元素是否还未打开;
- CSS 是否尚未加载;
- 是否测量了隐藏的 Portal 内容;
- 是否误把
contentRect、border box 和视觉 transform 混为一谈。
如果尺寸会变化,应增加 ResizeObserver,而不是在一次 Effect 中无限重测。
6. 聚焦后马上丢失
常见原因包括:
- 后续渲染替换了节点;
- Effect 依赖变化导致另一个逻辑重新聚焦;
- 弹窗关闭后焦点回收代码执行;
- 测试环境没有真实布局或浏览器焦点支持;
- 某个组件在 hydration 后重新生成 DOM。
诊断时观察浏览器 Elements 面板中的节点身份,而不仅是相同的标签和文本;同时记录焦点变化:
document.addEventListener("focusin", (event) => {
console.log("focus:", event.target);
});
十二、如何划定命令式边界
一个组件是否应该暴露 ref,可以用以下因果条件判断:
外部是否需要操作一个非声明式浏览器对象?
↓
是:考虑 DOM ref 或命令式句柄
↓
外部是否必须知道内部 DOM 结构?
↓
尽量否:通过 useImperativeHandle 暴露最小 API
适合通过 ref 暴露的能力:
type EditorHandle = {
focus: () => void;
selectAll: () => void;
scrollToSelection: () => void;
};
不适合通过 ref 暴露的能力:
type BadHandle = {
inputElement: HTMLInputElement;
internalWrapper: HTMLDivElement;
setPrivateFlag: (value: boolean) => void;
};
前者表达稳定的用户行为,后者泄漏实现细节。命令式句柄仍然应服从组件的状态模型:如果一个动作会改变用户可见状态,最好由组件内部维护一致性,或通过明确的回调与 state 流转,而不是让父组件直接改 DOM 后再期待 React 知道发生了什么。
十三、一个完整的 React 19 示例
下面的组件同时展示:
- 直接接收
refprop; - 暴露有限命令;
- 打开后聚焦;
- 使用
ResizeObserver更新测量结果; - 卸载时安全清理;
- 不把内部 DOM 暴露给父组件。
"use client";
import {
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
} from "react";
export type SearchPanelHandle = {
focusInput: () => void;
clearInput: () => void;
};
type SearchPanelProps = {
open: boolean;
onSubmit: (query: string) => void;
ref?: React.Ref<SearchPanelHandle>;
};
export function SearchPanel({
open,
onSubmit,
ref,
}: SearchPanelProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [width, setWidth] = useState<number | null>(null);
useImperativeHandle(
ref,
() => ({
focusInput() {
inputRef.current?.focus();
},
clearInput() {
setQuery("");
inputRef.current?.focus();
},
}),
[]
);
useEffect(() => {
if (open) {
inputRef.current?.focus();
}
}, [open]);
useLayoutEffect(() => {
const node = inputRef.current;
if (!node) return;
const update = (nextWidth: number) => {
setWidth((previous) =>
previous === nextWidth ? previous : nextWidth
);
};
update(node.getBoundingClientRect().width);
const observer = new ResizeObserver(([entry]) => {
if (entry) {
update(entry.contentRect.width);
}
});
observer.observe(node);
return () => {
observer.disconnect();
};
}, [open]);
if (!open) {
return null;
}
return (
<form
role="search"
onSubmit={(event) => {
event.preventDefault();
onSubmit(query);
}}
>
<label htmlFor="search-query">关键词</label>
<input
id="search-query"
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<button type="submit">搜索</button>
<button
type="button"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
>
清空
</button>
<small>
输入框宽度:
{width === null ? "测量中" : `${Math.round(width)}px`}
</small>
</form>
);
}
父组件:
function SearchPage() {
const [open, setOpen] = useState(false);
const panelRef = useRef<SearchPanelHandle>(null);
return (
<>
<button
onClick={() => {
setOpen(true);
// 这里不能立即假设面板已提交。
// 聚焦由 SearchPanel 的 open Effect 完成。
}}
>
打开搜索
</button>
<button onClick={() => panelRef.current?.focusInput()}>
聚焦搜索框
</button>
<SearchPanel
ref={panelRef}
open={open}
onSubmit={(query) => {
console.log("提交查询:", query);
}}
/>
</>
);
}
关键时序如下:
点击“打开搜索”
↓
setOpen(true)
↓
SearchPanel 渲染出 form 和 input
↓
React 提交 DOM,并绑定 inputRef
↓
open Effect 调用 focus()
↓
layout Effect 首次测量
↓
ResizeObserver 继续监听尺寸变化
clearInput 修改的是 React state,而不是直接修改受控 input 的 DOM value,因此 React 的数据源仍然唯一。对外只暴露 focusInput 和 clearInput,父组件无需知道内部使用了哪个 HTML 元素。
十四、规范保证、实现事实与工程取舍
需要区分三类结论:
React API 的规范语义
useRef在组件实例生命周期内返回稳定的 ref 对象;- 修改
ref.current不会触发 React 渲染; - DOM ref 在节点提交后指向节点,在解除绑定后为
null; useImperativeHandle定义外部通过 ref 看到的值;- React 19 支持函数组件直接接收
refprop。
浏览器行为
focus()受浏览器焦点策略、用户手势和节点可见性影响;getBoundingClientRect()返回当前布局结果;ResizeObserver监听尺寸变化,不保证监听位置变化;display: none、字体加载、缩放和 transform 都可能影响测量结果。
工程建议
- 视觉首帧依赖测量时考虑
useLayoutEffect; - 非视觉同步优先使用
useEffect; - 外部库初始化必须配套清理;
- 跨版本组件库应保守评估 React 19 的直接 ref prop 写法;
- 对外优先暴露语义化命令,而不是内部 DOM 节点。
ref 的价值在于连接 React 与那些无法仅靠 JSX 描述的对象:焦点、布局、媒体、动画库和外部实例。它不是第二套 state,也不是绕过组件边界的通行证。把 DOM 访问放在提交之后,把外部资源放进可清理的 Effect,把可见状态留在 React 数据流中,再用最小命令式句柄连接两者,才能让 ref 在并发渲染、动态布局、可访问性和服务端边界下保持可预测。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Effect 完整指南:同步外部系统、依赖、清理和竞态
- 下一篇:React 表单工程:受控组件、校验、异步提交、错误与性能
- 延伸:React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论