未命名文章

·9 min read

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={...} />

不用 forwardRefref 就传不进来,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() → 弹窗打开。


六、两种方式的对比

对比项forwardRef 方式props 传 ref 方式
ref 传递方式React 内置机制,用 ref 属性作为普通 prop 传入
TypeScript 支持泛型推导更严格需手动声明 RefObject
适用场景希望外部用 ref={...} 的标准写法内部组件,不在意外部 API 风格
项目示例AuditActionsBtnGroupRejectModalCancelSignModal

七、深入原理

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()opentrue → 弹窗渲染。


写于 2026-07-07,结合 kpay-crm-web 项目代码整理。

Twitter