React Ref 模式:forwardRef、useImperativeHandle 与 Ref 转发
Ref 不只是"引用 DOM"——在组件库里它决定 API 体验。本文讲清 useRef(值容器/不触发重渲染)、forwardRef(转发 ref 给子组件)、useImperativeHandle(暴露受控 API)、ref callback,以及 React 19 的 ref as prop(不用 forwardRef 了)。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
很多开发者对 ref 的认知停留在「拿到 DOM 调一下 focus」这个层面。一旦开始写组件库,你会发现 ref 才是组件 API 体验的真正分水岭:一个 Input 组件到底是让使用者拿到 <input> 元素,还是拿到一个 { focus(), clear() } 的受控句柄,决定了这个组件好不好用、好不好维护。
这篇文章把 ref 的几条主线一次讲透:useRef 的两副面孔、forwardRef 的转发、useImperativeHandle 的受控暴露、函数 ref 的精确控制,以及 React 19 里「ref 作为普通 prop」带来的简化。每一段都配 TSX,不绕弯子。
一、useRef 的两副面孔
useRef 返回一个在整个组件生命周期内稳定不变的对象 { current: T }。稳定到什么程度?每次渲染拿到的是同一个引用。它有两个截然不同的用途,混淆它们是新手最常见的错误。
面孔一:引用 DOM 元素
把 ref 挂到 JSX 的 ref 属性上,React 在 commit 阶段会把对应 DOM 节点赋给 ref.current:
function SearchBox() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// 挂载后自动聚焦
inputRef.current?.focus();
}, []);
return <input ref={inputRef} placeholder="搜索…" />;
}
注意初始值是 null,类型是 RefObject<HTMLInputElement | null>。组件卸载时 React 会把 current 重新置为 null——这点很重要,后面讲陷阱时会用到。
面孔二:不触发重渲染的可变容器
这是 useRef 更隐蔽、也更容易被滥用的用途:存一个任意值,改它不会让组件重新渲染。
function Timer() {
const timerRef = useRef<number | null>(null);
useEffect(() => {
timerRef.current = window.setInterval(() => {
console.log('tick');
}, 1000);
return () => {
if (timerRef.current) clearInterval(timerRef.current);
};
}, []);
return <p>看控制台</p>;
}
这里的 timer ID 不需要驱动 UI,放进 state 纯属浪费(还要触发一次没意义的重渲染)。放进 ref 刚好:它活着、可变、稳定、且不吵不闹。
判别原则:如果一个值需要驱动渲染,用 useState;如果它只是「组件的附注信息」(timer、上一次的某个值、第三方库的实例),用 useRef。我见过把请求结果存进 ref 然后纳闷界面为什么不更新的代码——根因都是把 ref 当成了「不会触发更新的 state」,而恰恰漏掉了「不会触发更新」这一句。
useRef 的常见场景清单:
- DOM/组件实例引用(focus、滚动、测量尺寸)
- 定时器、订阅、AbortController 的句柄
useEffect之间共享的「上次值」(previousRef.current)- 防抖/节流里存「上一次调用时间」
二、forwardRef:把 ref 传下去
直接给一个自定义函数组件传 ref 是不行的——React 默认不把 ref 当成普通 prop,它会报错或被静默忽略。从 React 16.8 到 React 18,标准做法是用 forwardRef 包一层:
type FancyInputProps = {
label: string;
defaultValue?: string;
};
const FancyInput = forwardRef<HTMLInputElement, FancyInputProps>(
function FancyInput({ label, defaultValue }, ref) {
return (
<label>
<span>{label}</span>
<input ref={ref} defaultValue={defaultValue} />
</label>
);
}
);
forwardRef<RefType, PropsType> 的两个泛型参数:第一个是 ref 指向的类型(这里是 HTMLInputElement),第二个是 props 类型。第二个参数 ref 就是父组件传进来的 ref,我们原封不动地挂到内部的 <input> 上——这就是「转发」。
使用者侧:
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<FancyInput ref={inputRef} label="邮箱" defaultValue="" />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
</>
);
}
为什么要给组件传 ref? 因为很多场景下,父组件确实需要操作子组件渲染出来的那个 DOM:聚焦、清空、滚动到视野内、读取 selection。如果不提供 ref 通道,使用者就只能被迫通过受控 props + 回调去绕,API 变得很别扭。组件库的黄金法则是:任何渲染单一宿主组件(input、textarea、div 等)的自定义组件,都应当支持 ref 转发,否则它就是一个「黑盒」,使用者拿不到里面那个最关键的节点。
三、useImperativeHandle:暴露受控 API,而不是裸 DOM
直接转发 ref 的问题在于:使用者拿到了整个原生 DOM。他可以随便改 value、改样式、改 innerHTML,你的组件内部状态和外部的直接操作之间会出现不一致。更糟的是,一旦你想把内部的 <input> 换成 <div contenteditable>,所有依赖「这是 input」的调用方都会炸。
useImperativeHandle 给你一种更克制的方式:只暴露你愿意暴露的方法,把内部实现藏起来。
export type InputHandle = {
focus: () => void;
clear: () => void;
getValue: () => string;
};
type FancyInputProps = { label: string };
const FancyInput = forwardRef<InputHandle, FancyInputProps>(
function FancyInput({ label }, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = '';
inputRef.current?.focus();
},
getValue: () => inputRef.current?.value ?? '',
}),
[] // 依赖:通常为空,因为这些方法只依赖稳定的 inputRef
);
return (
<label>
<span>{label}</span>
<input ref={inputRef} />
</label>
);
}
);
注意三个关键点:
- ref 泛型现在指向自定义句柄类型
InputHandle,不再是HTMLInputElement。这是契约:你向使用者承诺的是「这组方法」,而不是「这个节点」。 useImperativeHandle(ref, createHandle, deps)的第三个参数是依赖数组。React 在依赖变化时才会重新创建 handle。绝大多数情况下 handle 内部只引用稳定的 ref,所以传[]即可;如果 handle 方法闭包了 props,记得把那些 props 加进依赖,否则会拿到旧值。- handle 在 commit 阶段才挂到 ref 上,所以渲染阶段读
ref.current永远是null(详见第七节)。
什么时候该用 useImperativeHandle,什么时候直接转发? 我的判断标准是「有没有抽象边界」:
- 如果你做的是一个薄包装(
FancyDiv、Button就是套了个 className),直接转发 DOM 没问题——使用者本来就该把它当成那个宿主元素。 - 如果你做的是一个有自己心智模型的组件(
Form、Combobox、Modal、VideoPlayer),暴露受控 API 几乎总是对的。你的内部可能是<input>也可能是十个 div 嵌套,调用方不应该关心;他们只要focus()、open()、play()。
这就是组件库 API 设计的核心张力:ref 暴露什么,就是你对使用者承诺什么。承诺越窄,将来重构越自由。
四、ref callback:函数 ref 的精确控制
ref 不只能传一个 useRef 对象,也能传一个函数。React 在节点挂载时调用它并传入 DOM 节点,在卸载时调用它并传入 null。
function Measure() {
const [width, setWidth] = useState(0);
return (
<div
ref={(node) => {
if (node) {
setWidth(node.getBoundingClientRect().width);
}
}}
>
宽度:{width}px
</div>
);
}
函数 ref 的真正价值在于**「拿到节点的那个瞬间」要做点事**:测量尺寸、注册第三方库(图表、编辑器)、绑定原生事件监听。
React 19 的 cleanup:把 setup 和 teardown 写在一起
React 19 给函数 ref 加了一个长期缺失的能力:返回一个清理函数。以前的写法是「挂载回调」和「卸载回调」割裂在两个地方(一个在 ref 函数里,一个在 useEffect return 里),现在可以合二为一:
function Editor() {
return (
<div
ref={(node) => {
if (!node) return;
const editor = createThirdPartyEditor(node);
// React 19:返回的函数会在卸载或 ref 变化时调用
return () => {
editor.destroy();
};
}}
/>
);
}
把「创建」和「销毁」写在相邻几行,可读性比拆到 useEffect 里好得多,也更不容易漏掉清理逻辑。
用 useState 当「可触发渲染的 ref」
如果你需要「拿到节点」并且「这个节点变化要触发渲染」(比如根据节点尺寸做布局),一个常见技巧是把 ref 函数本身交给 useState 的 setter:
function ResponsiveBox() {
const [node, setNode] = useState<HTMLDivElement | null>(null);
useEffect(() => {
if (!node) return;
const observer = new ResizeObserver((entries) => {
console.log(entries[0].contentRect);
});
observer.observe(node);
return () => observer.disconnect();
}, [node]);
return <div ref={setNode}>观察我</div>;
}
setNode 是稳定函数,可以直接当 ref callback 用;node 变化会触发渲染并把最新节点带进 useEffect。这比手写一个 useRef + forceUpdate 干净得多。
五、React 19:ref 作为普通 prop
前面四节里的 forwardRef,在 React 19 里不再是必须的。从 19 开始,ref 可以像任何普通 prop 一样直接传给函数组件,也能直接从 props 里解构出来:
type FancyInputProps = {
label: string;
ref?: React.Ref<HTMLInputElement>;
};
function FancyInput({ label, ref }: FancyInputProps) {
return (
<label>
<span>{label}</span>
<input ref={ref} />
</label>
);
}
调用方代码完全不变:
const inputRef = useRef<HTMLInputElement>(null);
<FancyInput ref={inputRef} label="邮箱" />;
这一改动看似微小,实际上解决了几件烦人的事:
- 泛型少一层。以前
forwardRef<Ref, Props>的两个泛型参数顺序经常写反;现在 props 类型里直接声明ref?: React.Ref<T>,跟其他 prop 写法一致。 - displayName 不再需要单独设。
forwardRef包出来的组件默认displayName是"FancyInput",但调试工具里有时候会显示成匿名函数;普通函数组件天然有名字。 - spread props 更顺手。
{...props}现在可以连带ref一起传下去,不需要单独拎出来。
forwardRef 在 React 19 里仍然可用(被标记为「不再推荐」,会有一条控制台提示,但没有破坏性),存量代码不必急着改。新代码建议直接用 prop 形式——更少包装、更少心智负担。
如果你做组件库要同时兼容 React 18 和 19,短期内还得继续用 forwardRef。一旦放弃 18 支持,果断删掉这层壳。
六、实战:一个能 focus 和 clear 的输入框
把前面几节串起来。我们做一个受控输入框,对外只暴露 focus 和 clear,不暴露裸 DOM,并支持 React 19 的 ref-as-prop(用兼容写法,同时跑在 18/19 上)。
import {
forwardRef,
useImperativeHandle,
useRef,
type ChangeEvent,
} from 'react';
// 对外契约:调用方能做的就这两件事
export type SmartInputHandle = {
focus: () => void;
clear: () => void;
};
type SmartInputProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
};
export const SmartInput = forwardRef<SmartInputHandle, SmartInputProps>(
function SmartInput({ value, onChange, placeholder, disabled }, ref) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => {
onChange('');
// 清空后顺手聚焦,符合用户直觉
inputRef.current?.focus();
},
}),
[onChange] // onChange 来自闭包,必须进依赖
);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<input
ref={inputRef}
value={value}
onChange={handleChange}
placeholder={placeholder}
disabled={disabled}
/>
);
}
);
使用者侧:
function Demo() {
const inputRef = useRef<SmartInputHandle>(null);
const [text, setText] = useState('');
return (
<>
<SmartInput ref={inputRef} value={text} onChange={setText} />
<button onClick={() => inputRef.current?.focus()}>聚焦</button>
<button onClick={() => inputRef.current?.clear()}>清空</button>
</>
);
}
这个设计有几个值得说道的地方:
clear()同时调用了onChange('')。注意我们把「清空」定义成了业务语义(重置受控值 + 聚焦),而不是 DOM 操作(input.value = '')。这就是受控 API 的价值:调用方一句clear()拿到的是「符合直觉的行为」,而不是「对 DOM 的低级操作」。如果只是裸转发 DOM,调用方得自己写「清 value、同步 state、再 focus」三步,还容易漏。- 依赖数组写了
[onChange]。clear闭包里用了onChange,如果调用方每次渲染都传新函数(很常见),不写依赖会拿到旧的onChange,清空后 state 不更新。这是useImperativeHandle最隐蔽的坑。 - 内部用
useRef+useImperativeHandle双 ref。一个 ref 挂 DOM(私有的inputRef),一个 ref 暴露 API(外部传入的ref)。两者职责分离,互不污染。
如果只面向 React 19,可以去掉 forwardRef:
type SmartInputProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
ref?: React.Ref<SmartInputHandle>;
};
export function SmartInput({ value, onChange, placeholder, disabled, ref }: SmartInputProps) {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
onChange('');
inputRef.current?.focus();
},
}), [onChange]);
// …其余 JSX 不变
}
效果完全一致,少一层包装。
七、陷阱:render 阶段不要读写 ref.current
这是 ref 相关 bug 里最高频的一类,也是面试时能筛掉一大批人的考点。
铁律:ref.current 的赋值发生在 commit 阶段,不是 render 阶段。在组件函数体(render)里读写 ref.current 是不安全的。
反面教材:
function Bad() {
const countRef = useRef(0);
countRef.current += 1; // ❌ 在 render 阶段改 ref
return <div>渲染次数:{countRef.current}</div>;
}
这段代码看起来能跑,实际上有两个严重问题:
- StrictMode 下会双调用。React 的开发模式会故意把 render 调用两次来帮你发现副作用,
countRef.current会 +2 而不是 +1,数字对不上。 - render 之间没有隔离。React 可以在「不 commit」的情况下调用你的 render(并发特性下可能丢弃某次渲染结果)。在 render 里改 ref,等于在一个可能被丢弃的渲染里留下了副作用——下一次 commit 时 ref 的值和你以为的不一致。
更隐蔽的版本——在 render 里读 ref 期望拿到「这次挂载后的节点」:
function AlsoBad() {
const ref = useRef<HTMLDivElement>(null);
// ❌ render 阶段 ref.current 还是上一次的值(首次渲染时是 null)
const width = ref.current?.getBoundingClientRect().width ?? 0;
return <div ref={ref}>宽度 {width}</div>;
}
首次渲染时 ref.current 必然是 null——因为 commit 还没发生。你读到的是个过期值,而且因为读 ref 不触发渲染,就算拿到值界面也不会更新。
正确做法:
- 需要副作用(订阅、测量、改 ref)——放进
useEffect或useLayoutEffect。 - 需要一个「驱动渲染的值」——用
useState,别用 ref。 - 需要在 render 里算派生值——基于 props/state 算,不要读 ref。
function Good() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
// ✅ commit 之后,DOM 已就位,测量准确
if (ref.current) {
setWidth(ref.current.getBoundingClientRect().width);
}
}, []);
return <div ref={ref}>宽度 {width}</div>;
}
一句话记忆:render 是纯函数,它不能产生任何可观测的副作用;ref 的读写都是副作用,所以不属于 render。把这条刻进脑子,能少踩一半的 ref 坑。
另一个相关的小陷阱:别在 render 里判断 ref.current 是否存在来决定渲染什么。比如 if (ref.current) return <A/>; return <B/>; 这种写法,因为 ref 在 render 时是上一次的值,会导致界面与实际 DOM 错位。如果要根据「是否挂载」渲染不同内容,用 useState 维护一个 mounted 标志,在 useEffect 里 setMounted(true)。
小结
useRef有两副面孔:引用 DOM 元素,或当「不触发重渲染的可变容器」。判别标准——要不要驱动 UI?要就用useState,不要就用useRef。forwardRef解决「把 ref 传给自定义组件」:任何渲染单一宿主元素的组件都应支持 ref 转发,否则它就是个黑盒。React 19 起ref可作为普通 prop 直接传入,forwardRef不再是必需(仍兼容,但已不推荐)。useImperativeHandle用来定义组件对外契约:暴露focus/clear/open这类受控方法,而不是裸 DOM。承诺越窄,重构越自由;记得把闭包里用到的 props 放进依赖数组。- 函数 ref 适合「拿到节点的那一瞬间」做事:测量、注册第三方库。React 19 起支持返回 cleanup,setup 与 teardown 终于能写在一起。
- render 阶段绝不读写
ref.current:ref 在 commit 阶段赋值,render 期间读写拿到的是过期值,还会被 StrictMode 双调用放大。render 是纯函数,ref 的读写都是副作用——副作用的归宿是useEffect。
金句:ref 不是「另一个 state」,它是「组件逃出 React 渲染周期的一根管子」。管子该不该接、接出去暴露什么,就是组件 API 设计的全部功夫。
Related