forwardRef 与 useImperativeHandle 详解
结合 kpay-crm-web 项目真实代码,由浅入深讲解。
一、先理解问题:ref 为什么需要"转发"?
React 组件默认情况下,父组件给子组件传一个 ref,这个 ref 只能拿到原生 DOM 节点(如 <div>、<input>)。
但如果子组件是一个函数式组件,直接传 ref 会报错:
// 这样写会报警告,ref 拿不到任何东西
<MyButton ref={someRef} />
原因:函数式组件没有实例,React 不知道把 ref 绑到哪里。
forwardRef 就是解决这个问题的——它让函数式组件主动接收并转发 ref。
二、forwardRef:让函数组件接收 ref
2.1 基本语法
import { forwardRef } from 'react';
const MyComponent = forwardRef<Ref类型, Props类型>((props, ref) => {
// ref 在这里可用了
return <div ref={ref}>...</div>;
});
forwardRef 接收一个渲染函数,这个函数比普通函数组件多一个 ref 参数(第二个参数)。
2.2 最简单的例子:把 ref 绑到 DOM
// 子组件:把 ref 转发到内部的 <input>
const FancyInput = forwardRef<HTMLInputElement, {}>((props, ref) => {
return <input ref={ref} style={{ border: '2px solid blue' }} />;
});
// 父组件:用 ref 直接操作 input
const Parent = () => {
const inputRef = useRef<HTMLInputElement>(null);
const focusInput = () => {
inputRef.current?.focus(); // 直接调用 DOM 方法
};
return (
<>
<FancyInput ref={inputRef} />
<button onClick={focusInput}>聚焦输入框</button>
</>
);
};
父组件通过 ref 拿到了 <input> 的 DOM 节点,可以直接调用 .focus()。
三、项目中的 forwardRef:AuditActionsBtnGroup
文件:src/pages/MerchantApproval/components/hk/AuditActionsBtnGroup.tsx 第 45 行
const AuditActionsBtnGroup = forwardRef<{ allowReturn: () => void }, AuditActionsBtnGroupProps>(
({ merchantId, auditState, reload }, ref) => {
void ref; // forwardRef 保留接口,暂无需暴露句柄
// ...
},
);
这里 forwardRef 的泛型参数含义:
- 第一个泛型
{ allowReturn: () => void }:告诉 TypeScript,这个 ref 未来会暴露一个对象,里面有allowReturn方法。 - 第二个泛型
AuditActionsBtnGroupProps:组件 props 的类型。
当前 void ref 意思是"暂时用不到,但接口预留着"——注释写得很清楚:forwardRef 保留接口,供 EditAuditInfo 的返回按钮调用(与舊 BtnGroup 接口一致)。
关键理解:用了 forwardRef,父组件 EditAuditInfo 就可以这样写:
const btnGroupRef = useRef<{ allowReturn: () => void }>(null);
// 某处调用:
btnGroupRef.current?.allowReturn();
// JSX 中:
<AuditActionsBtnGroup ref={btnGroupRef} merchantId={...} />
不用 forwardRef,ref 就传不进来,TypeScript 直接报错。
四、useImperativeHandle:自定义 ref 暴露的内容
4.1 解决什么问题?
forwardRef 可以把 ref 转发到 DOM,但很多时候我们不想暴露整个 DOM,只想暴露几个特定方法。
比如:父组件需要"打开弹窗",但不需要知道弹窗内部任何 DOM 结构。
useImperativeHandle 就是专门做这件事的:拦截 ref,只暴露你指定的方法。
4.2 基本语法
useImperativeHandle(ref, () => ({
// 这里返回的对象,就是父组件 ref.current 拿到的
open: () => { /* ... */ },
close: () => { /* ... */ },
}));
第一个参数:ref(来自 forwardRef 或者直接传入的 RefObject)
第二个参数:工厂函数,返回要暴露的对象
第三个参数(可选):依赖数组,和 useEffect 一样
4.3 最简单的例子
// 子组件:只暴露 open 和 close 方法
const MyModal = forwardRef<{ open: () => void; close: () => void }, {}>((props, ref) => {
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({
open: () => setVisible(true),
close: () => setVisible(false),
}));
return visible ? <div>弹窗内容</div> : null;
});
// 父组件:命令式地控制弹窗
const Parent = () => {
const modalRef = useRef<{ open: () => void; close: () => void }>(null);
return (
<>
<button onClick={() => modalRef.current?.open()}>打开</button>
<MyModal ref={modalRef} />
</>
);
};
五、项目中的 useImperativeHandle:RejectModal
文件:src/pages/Enterprise/components/RejectModal/index.tsx 第 80 行
/** 抛出命令式方法供父组件调用 */
useImperativeHandle(rejectRef, () => ({
open: () => showModal(),
}));
注意:这里的 rejectRef 不是通过 forwardRef 传进来的,而是通过 props 直接传入的:
interface RejectModalProps {
/** 命令式句柄 */
rejectRef: RefObject<IModalRef>;
onConfirm: (values: RejectFormValues) => Promise<boolean>;
}
const RejectModal: React.FC<RejectModalProps> = (props) => {
const { rejectRef, onConfirm } = props;
// ...
useImperativeHandle(rejectRef, () => ({
open: () => showModal(),
}));
};
这是项目里的一个特殊模式:把 ref 当普通 prop 传进去,而不是用 forwardRef。两种方式效果相同,只是写法不同。
IModalRef 类型定义在 src/pages/Enterprise/utils.ts,大概是:
export interface IModalRef {
open: () => void;
}
父组件(AuditActionsBtnGroup)怎么用:
// 1. 创建 ref
const rejectRef = useRef<IModalRef>(null);
// 2. 传给 RejectModal
<RejectModal rejectRef={rejectRef} onConfirm={onRejectSign} />
// 3. 在需要的地方打开弹窗
onOpenReject: () => rejectRef.current?.open(),
调用链路:用户点击"拒绝"按钮 → onOpenReject() → rejectRef.current?.open() → RejectModal 内部的 showModal() → 弹窗打开。
六、两种方式的对比
七、深入原理
7.1 forwardRef 做了什么?
React 内部,ref 属性是"保留关键字"——就像 key 一样,不会出现在 props 里。
forwardRef 本质上是把组件包了一层,创建了一个特殊的 React 元素类型($$typeof: REACT_FORWARD_REF_TYPE),让 React 在渲染时把 ref 单独作为第二参数传给渲染函数。
React 渲染时:
普通组件 → renderFunction(props)
forwardRef 组件 → renderFunction(props, ref) ← 多了 ref
7.2 useImperativeHandle 做了什么?
它在 React 的 commit 阶段(DOM 更新完成后)执行,修改 ref.current 的值。
正常情况:ref.current = DOM 节点 或 组件实例
useImperativeHandle:ref.current = 你返回的自定义对象
本质是个副作用(Effect),所以它有依赖数组参数,行为和 useEffect 一致——依赖变化时重新执行,组件卸载时 ref.current 置回 null。
7.3 命令式 vs 声明式
React 推崇声明式(数据驱动视图),但弹窗、焦点、动画这些场景,用声明式写起来反而麻烦(需要把 visible 状态提升到父组件)。
forwardRef + useImperativeHandle 提供了一个合规的命令式逃生舱:
- 状态依然在子组件内部管理(符合封装原则)
- 父组件通过 ref 发命令(简洁直接)
- TypeScript 类型全覆盖(安全)
八、完整调用链路总结(以 RejectModal 为例)
AuditActionsBtnGroup
│
├─ const rejectRef = useRef<IModalRef>(null)
│
├─ <RejectModal rejectRef={rejectRef} onConfirm={onRejectSign} />
│
└─ useMerchantApprovalActionButtons({
onOpenReject: () => rejectRef.current?.open() ← 触发点
})
RejectModal(内部)
│
├─ const [open, { setTrue: showModal }] = useBoolean(false)
│
└─ useImperativeHandle(rejectRef, () => ({
open: () => showModal() ← ref.current.open 指向这里
}))
用户点"拒绝"→ rejectRef.current.open() → showModal() → open 变 true → 弹窗渲染。
写于 2026-07-07,结合 kpay-crm-web 项目代码整理。