先建立一个整体认识
这两个 API 都和 ref 有关:
forwardRef:让父组件传入的 ref,穿过当前组件,绑定到内部元素上。useImperativeHandle:自定义父组件通过 ref 能拿到什么。
可以先把它们理解成:
父组件
│
│ ref
▼
自定义组件 Button
│
│ forwardRef 转发
▼
内部真实的 <button>
而 useImperativeHandle 会在中间加一层“接口包装”:
父组件
│
│ ref.current
▼
自定义的方法对象
{
focus(),
click(),
shake()
}
│
▼
内部真实 DOM
一、ref 本身是什么
在 React 中,ref 通常用来保存一个对象或引用,并且修改它不会触发重新渲染。
例如获取一个真实的 <button> DOM:
import { useRef } from "react";
export function App() {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = () => {
console.log(buttonRef.current);
buttonRef.current?.focus();
};
return (
<>
<button ref={buttonRef}>保存</button>
<button onClick={handleClick}>让保存按钮获得焦点</button>
</>
);
}
组件挂载以后:
buttonRef.current
大致就是这个真实 DOM:
<button>保存</button>
因此可以调用浏览器提供的 DOM 方法:
buttonRef.current?.focus();
buttonRef.current?.click();
buttonRef.current?.scrollIntoView();
buttonRef.current?.getBoundingClientRect();
React 会在 DOM 节点创建后将它放入 ref.current,节点卸载时通常会重新设为 null。(React)
二、为什么自定义组件需要 forwardRef
直接给原生元素传 ref 很简单:
<button ref={buttonRef}>按钮</button>
但你的 Button 是自定义组件:
<Button ref={buttonRef}>按钮</Button>
在 React 18 以及更早版本中,普通函数组件不能像普通 props 那样直接接收这个特殊的 ref:
// React 18 下不能这样接收 ref
function Button(props) {
return <button />;
}
因为父组件写下:
<Button ref={buttonRef} />
父组件真正想引用的通常不是 Button 这个函数,而是 Button 内部最终渲染出来的真实 <button>。
所以需要 forwardRef 把它转发进去。
三、你代码里的 forwardRef
你的代码:
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
asChild = false,
className,
variant = "default",
size = "default",
...props
},
ref,
) => {
const ButtonPrimitive = asChild ? Slot : "button";
return (
<ButtonPrimitive
{...props}
ref={ref}
className={cn(
"...",
variants[variant],
sizes[size],
className,
)}
/>
);
},
);
重点是:
forwardRef<HTMLButtonElement, ButtonProps>
它有两个泛型参数。
第一个参数:HTMLButtonElement
forwardRef<HTMLButtonElement, ButtonProps>
表示这个组件对外暴露的 ref,正常情况下指向一个 <button> DOM。
所以父组件可以这样写:
const buttonRef = useRef<HTMLButtonElement>(null);
并调用:
buttonRef.current?.focus();
buttonRef.current?.click();
第二个参数:ButtonProps
表示组件接收的 props 类型:
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
asChild?: boolean;
variant?: keyof typeof variants;
size?: keyof typeof sizes;
}
所以整个表达式可以理解为:
forwardRef<
ref最终指向的类型,
组件props的类型
>
也就是:
forwardRef<HTMLButtonElement, ButtonProps>
四、forwardRef 的回调为什么有两个参数
普通函数组件通常只有一个参数:
function Button(props) {
return <button />;
}
经过 forwardRef 包装后,渲染函数有两个参数:
forwardRef((props, ref) => {
return <button ref={ref} />;
});
分别是:
(props, ref)
props
普通组件属性:
<Button
className="..."
variant="outline"
disabled
>
保存
</Button>
这些都会进入 props。
ref
父组件传进来的引用:
<Button ref={buttonRef}>保存</Button>
这里的:
buttonRef
就会成为 forwardRef 回调中的第二个参数:
(props, ref) => {
// ref 就是父组件的 buttonRef
}
官方定义也是:forwardRef 接收一个渲染函数,React 使用 props 和 ref 调用它;返回的组件可以接收 ref。(React)
五、ref 是怎么穿过你的 Button 的
父组件:
import { useRef } from "react";
import { Button } from "./Button";
export function Page() {
const buttonRef = useRef<HTMLButtonElement>(null);
return (
<>
<Button ref={buttonRef}>
提交
</Button>
<button
onClick={() => {
buttonRef.current?.focus();
}}
>
聚焦提交按钮
</button>
</>
);
}
传递过程如下。
第一步:父组件传入
<Button ref={buttonRef}>
提交
</Button>
第二步:forwardRef 接收到
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
// 这里的 ref 就是父组件中的 buttonRef
},
);
第三步:传给内部元素
return <button {...props} ref={ref} />;
第四步:真实 DOM 写入 current
最终:
buttonRef.current
就是内部真实的:
<button>提交</button>
因此调用:
buttonRef.current?.focus();
就是在调用真实按钮 DOM 的 focus()。
六、没有 forwardRef 会怎么样
例如:
interface ButtonProps {
children: React.ReactNode;
}
function Button({ children }: ButtonProps) {
return <button>{children}</button>;
}
父组件希望这样使用:
const buttonRef = useRef<HTMLButtonElement>(null);
<Button ref={buttonRef}>保存</Button>
在 React 18 的组件写法里,普通函数组件没有把 ref 接收下来,更没有把它传给内部的 <button>。
所以父组件无法拿到内部按钮。
使用 forwardRef 后:
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ children }, ref) => {
return <button ref={ref}>{children}</button>;
},
);
这时才建立了完整链路。
七、你的 asChild 和 ref
你的代码里有:
const ButtonPrimitive = asChild ? Slot : "button";
当:
asChild={false}
最终渲染:
<button ref={ref} />
此时 ref.current 是:
HTMLButtonElement
但当:
<Button asChild>
<a href="/home">首页</a>
</Button>
Slot 会把 Button 的属性合并到这个 <a> 上。
最终真实元素更接近:
<a href="/home" className="...">
首页
</a>
这时 ref 实际可能指向:
HTMLAnchorElement
而你目前声明的是:
forwardRef<HTMLButtonElement, ButtonProps>
类型上仍然告诉 TypeScript:
ref.current 是 HTMLButtonElement
这存在一定的类型不准确问题。
例如:
const ref = useRef<HTMLButtonElement>(null);
<Button asChild ref={ref}>
<a href="/home">首页</a>
</Button>
运行时的元素是 <a>,但 TypeScript 认为它是 <button>。
对于简单 UI 组件库,这种写法比较常见;如果要做到严格类型安全,就需要实现更复杂的多态组件类型。这里不影响理解 forwardRef 的核心作用:它仍然负责把 ref 传到最终的真实元素上。
八、Button.displayName 是什么
你后面写了:
Button.displayName = "Button";
因为组件经过:
forwardRef(...)
包装后,在 React DevTools 或错误信息中,组件名称有时不够直观。
设置:
Button.displayName = "Button";
可以让开发工具更明确地显示组件名称。
它不影响组件功能,只影响调试体验。
九、React 19 中 forwardRef 的变化
需要注意版本差异。
从 React 19 开始,函数组件可以直接把 ref 当作 prop 接收,新组件不再必须使用 forwardRef;React 官方也表示 forwardRef 计划在未来版本中废弃。(React)
React 19 可以写成类似:
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
ref?: React.Ref<HTMLButtonElement>;
}
export function Button({
ref,
children,
...props
}: ButtonProps) {
return (
<button ref={ref} {...props}>
{children}
</button>
);
}
但目前很多组件库仍然需要兼容 React 18,因此依旧大量使用:
forwardRef(...)
所以你项目中的写法完全正常,而且是常见的兼容写法。
十、useImperativeHandle 是做什么的
useImperativeHandle 的作用是:
> 自定义父组件通过 ref.current 能拿到的内容。
默认情况下,你把 ref 直接传给 DOM:
<input ref={ref} />
父组件拿到的是完整的 HTMLInputElement:
ref.current?.focus();
ref.current?.select();
ref.current?.blur();
ref.current?.style.setProperty(...);
但是有时候你不想把整个 DOM 暴露出去,只希望父组件能调用几个特定能力:
ref.current?.focus();
ref.current?.clear();
这时可以使用:
useImperativeHandle(ref, () => ({
focus() {},
clear() {},
}));
官方将它定义为“自定义通过 ref 暴露的句柄”。(React)
十一、先看一个不使用 useImperativeHandle 的例子
import { forwardRef } from "react";
interface InputProps {
placeholder?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ placeholder }, ref) => {
return (
<input
ref={ref}
placeholder={placeholder}
/>
);
},
);
父组件:
import { useRef } from "react";
export function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<Input
ref={inputRef}
placeholder="请输入用户名"
/>
<button
onClick={() => {
inputRef.current?.focus();
}}
>
聚焦输入框
</button>
</>
);
}
此时:
inputRef.current
就是完整的 HTMLInputElement。
父组件可以直接操作 DOM:
inputRef.current?.focus();
if (inputRef.current) {
inputRef.current.value = "";
}
十二、使用 useImperativeHandle 自定义能力
现在我们不希望父组件直接操作:
inputRef.current.value
而是希望它调用:
inputRef.current.clear();
inputRef.current.focus();
首先定义对外暴露的接口:
export interface InputHandle {
focus: () => void;
clear: () => void;
select: () => void;
}
然后实现组件:
import {
forwardRef,
useImperativeHandle,
useRef,
} from "react";
interface InputProps {
placeholder?: string;
}
export interface InputHandle {
focus: () => void;
clear: () => void;
select: () => void;
}
export const Input = forwardRef<InputHandle, InputProps>(
({ placeholder }, ref) => {
const internalInputRef =
useRef<HTMLInputElement>(null);
useImperativeHandle(
ref,
() => ({
focus() {
internalInputRef.current?.focus();
},
clear() {
if (internalInputRef.current) {
internalInputRef.current.value = "";
}
},
select() {
internalInputRef.current?.select();
},
}),
[],
);
return (
<input
ref={internalInputRef}
placeholder={placeholder}
/>
);
},
);
Input.displayName = "Input";
父组件:
import { useRef } from "react";
import {
Input,
type InputHandle,
} from "./Input";
export function Form() {
const inputRef = useRef<InputHandle>(null);
return (
<>
<Input
ref={inputRef}
placeholder="请输入用户名"
/>
<button
onClick={() => {
inputRef.current?.focus();
}}
>
聚焦
</button>
<button
onClick={() => {
inputRef.current?.clear();
}}
>
清空
</button>
<button
onClick={() => {
inputRef.current?.select();
}}
>
全选
</button>
</>
);
}
注意,现在父组件中的 ref 类型变成了:
useRef<InputHandle>(null)
而不再是:
useRef<HTMLInputElement>(null)
因为父组件拿到的不再是 DOM,而是我们自定义的对象:
{
focus() {},
clear() {},
select() {}
}
十三、这里为什么需要两个 ref
在刚才的例子中:
export const Input = forwardRef<InputHandle, InputProps>(
(props, ref) => {
const internalInputRef =
useRef<HTMLInputElement>(null);
},
);
有两个 ref。
外部 ref
ref
它来自父组件:
<Input ref={inputRef} />
父组件最终通过它调用:
inputRef.current?.clear();
内部 ref
internalInputRef
它指向真正的 <input>:
<input ref={internalInputRef} />
内部方法通过它操作真实 DOM:
internalInputRef.current?.focus();
完整关系是:
父组件的 inputRef
│
│ ref
▼
useImperativeHandle
│
▼
{
focus(),
clear(),
select()
}
│
│ 内部调用
▼
internalInputRef
│
▼
真实的 <input>
十四、useImperativeHandle 的三个参数
基本语法:
useImperativeHandle(
ref,
createHandle,
dependencies,
);
官方 API 也是:
useImperativeHandle(ref, createHandle, dependencies?)
并且这个 Hook 本身返回 undefined。(React)
第一个参数:ref
父组件传入的 ref:
useImperativeHandle(ref, ...)
第二个参数:创建暴露对象的函数
() => ({
focus() {
internalInputRef.current?.focus();
},
clear() {
// ...
},
})
这个函数返回什么,父组件的:
ref.current
就是什么。
例如:
useImperativeHandle(ref, () => ({
name: "Dano",
focus() {},
}));
那么父组件获得:
ref.current?.name;
ref.current?.focus();
第三个参数:依赖数组
[]
作用和 useMemo、useEffect 的依赖数组相似。
例如:
useImperativeHandle(
ref,
() => ({
printValue() {
console.log(value);
},
}),
[value],
);
当 value 改变时,React 会重新创建暴露给父组件的对象。
如果内部方法使用了某个会变化的 props 或 state,就应该把它加入依赖数组。
十五、依赖数组的实际例子
interface CounterHandle {
getCount: () => number;
reset: () => void;
}
interface CounterProps {
initialValue?: number;
}
const Counter = forwardRef<CounterHandle, CounterProps>(
({ initialValue = 0 }, ref) => {
const [count, setCount] = useState(initialValue);
useImperativeHandle(
ref,
() => ({
getCount() {
return count;
},
reset() {
setCount(initialValue);
},
}),
[count, initialValue],
);
return (
<button onClick={() => setCount((n) => n + 1)}>
当前数量:{count}
</button>
);
},
);
这里暴露的方法使用了:
count
initialValue
所以依赖数组需要写:
[count, initialValue]
否则方法可能闭包住旧值,产生“父组件调用后得到过期数据”的问题。
不过这种“通过 ref 获取 React 状态”的设计通常不是首选。状态最好通过 props、回调或状态提升来传递,这里主要用来演示依赖关系。
十六、forwardRef 和 useImperativeHandle 的区别
这是最重要的区别。
只有 forwardRef
const Input = forwardRef<HTMLInputElement, InputProps>(
(props, ref) => {
return <input ref={ref} />;
},
);
父组件拿到的是:
HTMLInputElement
也就是整个 DOM 节点。
ref.current?.focus();
ref.current?.select();
ref.current?.blur();
forwardRef 加 useImperativeHandle
const Input = forwardRef<InputHandle, InputProps>(
(props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({
focus() {
inputRef.current?.focus();
},
}), []);
return <input ref={inputRef} />;
},
);
父组件拿到的是:
InputHandle
只有你主动暴露的方法:
ref.current?.focus();
不能再直接访问:
ref.current?.style;
ref.current?.value;
因为你没有把这些东西暴露出去。
可以记成一句话:
> forwardRef 决定 ref 能不能传进来,useImperativeHandle 决定传进来以后,父组件最终拿到什么。
十七、Button 需要 useImperativeHandle 吗
你目前的 Button:
<Button ref={buttonRef} />
父组件拿到原生按钮 DOM 已经很好用了:
buttonRef.current?.focus();
buttonRef.current?.click();
buttonRef.current?.scrollIntoView();
因此一般没必要使用 useImperativeHandle。
也就是说,你当前这样就够了:
<ButtonPrimitive ref={ref} />
只有当你希望 Button 暴露一些自定义操作时,才需要它。
例如暴露一个动画方法:
export interface ButtonHandle {
focus: () => void;
shake: () => void;
}
组件:
import {
forwardRef,
useImperativeHandle,
useRef,
} from "react";
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: keyof typeof variants;
size?: keyof typeof sizes;
}
export interface ButtonHandle {
focus: () => void;
shake: () => void;
}
export const Button = forwardRef<
ButtonHandle,
ButtonProps
>(
(
{
className,
variant = "default",
size = "default",
...props
},
ref,
) => {
const buttonRef =
useRef<HTMLButtonElement>(null);
useImperativeHandle(
ref,
() => ({
focus() {
buttonRef.current?.focus();
},
shake() {
const button = buttonRef.current;
if (!button) return;
button.animate(
[
{ transform: "translateX(0)" },
{ transform: "translateX(-5px)" },
{ transform: "translateX(5px)" },
{ transform: "translateX(0)" },
],
{
duration: 200,
},
);
},
}),
[],
);
return (
<button
{...props}
ref={buttonRef}
className={cn(
"inline-flex items-center justify-center",
variants[variant],
sizes[size],
className,
)}
/>
);
},
);
父组件:
export function Page() {
const buttonRef = useRef<ButtonHandle>(null);
return (
<>
<Button ref={buttonRef}>
提交
</Button>
<button
onClick={() => {
buttonRef.current?.shake();
}}
>
让提交按钮晃动
</button>
</>
);
}
十八、什么时候适合用 useImperativeHandle
比较适合的场景是那些必须“命令式执行”的行为:
ref.current?.focus();
ref.current?.scrollToTop();
ref.current?.selectAll();
ref.current?.play();
ref.current?.pause();
ref.current?.resetCanvas();
ref.current?.startAnimation();
例如:
- 聚焦输入框
- 滚动到某个节点
- 选中文字
- 控制音频、视频
- 控制 Canvas
- 对接第三方非 React 组件
- 触发一次动画
- 暴露富文本编辑器的命令
React 官方也建议不要过度使用 ref;它更适合聚焦、滚动、动画、文本选择等无法自然表达成 props 的命令式行为。(React)
十九、什么时候不应该用 useImperativeHandle
假设有一个弹窗,你可能想写:
modalRef.current?.open();
modalRef.current?.close();
但在 React 中,通常更推荐通过状态控制:
<Modal open={open} onOpenChange={setOpen} />
而不是:
<Modal ref={modalRef} />
modalRef.current?.open();
因为第一种是声明式的:
open={true}
表示“弹窗现在应该打开”。
第二种是命令式的:
modalRef.current.open()
表示“立刻执行打开操作”。
对于 UI 状态,优先使用 props 和 state;对于聚焦、滚动、播放动画等一次性动作,再使用 ref。官方文档也明确建议:能够用 prop 表达的状态,通常不要改成命令式 ref 接口。(React)
二十、最终记忆方式
forwardRef
作用:
把父组件的 ref 转发给组件内部的 DOM 或子组件
典型写法:
const Button = forwardRef<
HTMLButtonElement,
ButtonProps
>((props, ref) => {
return <button {...props} ref={ref} />;
});
父组件拿到:
HTMLButtonElement
useImperativeHandle
作用:
自定义父组件通过 ref.current 能调用的内容
典型写法:
useImperativeHandle(ref, () => ({
focus() {
internalRef.current?.focus();
},
clear() {
// ...
},
}), []);
父组件拿到:
{
focus(),
clear()
}
二者组合关系
const Component = forwardRef<Handle, Props>(
(props, ref) => {
const internalRef = useRef<HTMLElement>(null);
useImperativeHandle(ref, () => ({
someMethod() {
// 操作 internalRef
},
}), []);
return <div ref={internalRef} />;
},
);
一句话总结:
forwardRef:把 ref 接进来。
useImperativeHandle:规定 ref.current 暴露什么。