Back to Blog
· 5 min read

为什么需要 forwardRef 和 useImperativeHandle

react

React 的单向数据流是个好东西,但它也有边界。理解这个边界,才能真正掌握 ref。

如何实现点击表格单元格时,让输入框获得焦点?如何在表单提交时,直接调用内部组件的验证方法?这些是前端开发中很常见的场景,它们有着相似的行为:从父组件主动触发子组件的内部方法。props 和回调做不了这件事,这就是 forwardRef 和 useImperativeHandle 存在的理由。

数据流有个界限

React 的核心原则是单向数据流:

  • 父给子传数据:props
  • 子给父传数据:回调函数
// 父组件给子组件传数据
<ChildComponent data={someData} />

// 子组件给父组件传数据
<ChildComponent onDataChange={handleDataChange} />

这里的关键在于:无论 props 还是回调,方向都是确定的。props 是父组件”推送”数据给子组件,回调是子组件”传回”数据给父组件。

这个”界限”意味着什么?

问题来了:父组件没法直接调用子组件的方法。

// 父组件想调用子组件的方法?
const handleClick = () => {
  // 抱歉,props 做不到
};

props 的本质是配置,不是操作指令。父组件告诉子组件”用什么数据渲染”,而不是”去做什么”。

回调函数能做什么?

在说 ref 之前,先问自己:回调真的不够用吗?

回调擅长的事

回调非常适合数据驱动的场景:

场景回调怎么处理
子组件状态变化通知父组件onChange 回调
事件需要父组件处理onSubmit 回调
父组件需要最新数据实时同步
const Child = ({ onChange, onFocus }) => {
  return (
    <input
      onChange={(e) => onChange(e.target.value)}
      onFocus={() => onFocus && onFocus()}
    />
  );
};

回调不够用的场景

但有些事,回调做起来很别扭:

场景为什么回调不好用
父组件在特定时机调用子组件回调是子组件主动触发的
父组件需要获取子组件当前状态需要额外同步 state
命令式操作 DOM聚焦、滚动、测量尺寸
// 回调的局限
const Parent = () => {
  const handleSubmit = async () => {
    // 场景 1: 验证失败时,让第一个错误字段获得焦点
    // 回调做不到"父组件主动聚焦"

    // 场景 2: 获取子组件的内部状态
    // 需要额外的 state 同步
  };
};

核心区别:

  • 回调:子组件”主动”通知父组件
  • ref:父组件”主动”触发子组件

ref:数据流的”后门”

React 提供了 ref。ref 用来绕过数据流,直接访问 DOM 或组件实例

ref 能做什么

const Parent = () => {
  const inputRef = useRef(null);

  const handleClick = () => {
    inputRef.current.focus();
    inputRef.current.value = 'hello';
  };

  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleClick}>操作输入框</button>
    </>
  );
};

父组件直接操作了子组件的 DOM。这在 React 里是个”后门”。

ref 不是普通 prop

React 对 ref 做了特殊处理。看这个例子:

// 这样不行!
const Child = (props) => {
  return <input ref={props.myRef} />;
};

const Parent = () => {
  const myRef = useRef(null);
  return <Child myRef={myRef} />; // ref 传不过去
};

ref 属性会被 React 特殊处理,不会像普通 props 那样传递给组件。这就是需要 forwardRef 的原因。

forwardRef:让 ref 穿透组件

解决什么问题

让自定义组件能够接收并转发 ref

默认情况下,自定义组件无法接收 ref:

// 报错!
const Parent = () => {
  const childRef = useRef(null);
  return <ChildComponent ref={childRef} />;
};

错误提示:Function components cannot be given refs

基本用法

import { forwardRef, useRef } from 'react';

const ChildComponent = forwardRef((props, ref) => {
  return <input ref={ref} {...props} />;
});

const Parent = () => {
  const childRef = useRef(null);

  const handleClick = () => {
    childRef.current.focus();
  };

  return (
    <>
      <ChildComponent ref={childRef} />
      <button onClick={handleClick}>聚焦</button>
    </>
  );
};

原理

forwardRef 是高阶组件。React 检测到 forwardRef 创建的组件时,会把 ref 作为第二个参数传给 render 函数:

// 简化实现
function forwardRef(render) {
  return {
    $$typeof: Symbol.for('react.forward_ref'),
    render,
  };
}

// 使用时
const Component = forwardRef((props, ref) => {
  return <div ref={ref}>...</div>;
});

useImperativeHandle:定制暴露什么

解决什么问题

forwardRef 让 ref 能传给子组件。但有时候,不想暴露整个 DOM 节点,只想暴露几个方法

直接暴露 DOM 的问题

// 父组件可以做这些事:
childRef.current.focus();
childRef.current.value = 'x';
childRef.current.select();
childRef.current.blur();

父组件获得了完全的控制权。这会导致:

  • 耦合:父组件依赖了子组件的实现细节
  • 安全漏洞:父组件可以绕过子组件的业务逻辑
  • 维护困难:子组件重构可能破坏父组件

useImperativeHandle 用法

自定义 ref 暴露的内容:

import { forwardRef, useRef, useImperativeHandle } from 'react';

const CustomInput = forwardRef((props, ref) => {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current.focus(),
    getValue: () => inputRef.current.value,
    validate: () => inputRef.current.value.length > 0
  }), []);

  return <input ref={inputRef} {...props} />;
});

父组件只能调用暴露的方法:

const Parent = () => {
  const inputRef = useRef(null);

  const handleSubmit = () => {
    if (inputRef.current.validate()) {
      console.log(inputRef.current.getValue());
    }
  };

  return (
    <>
      <CustomInput ref={inputRef} />
      <button onClick={handleSubmit}>提交</button>
    </>
  );
};

原理是什么

理解 useImperativeHandle 要从 ref 的本质说起。

ref 是什么

useRef 创建的是一个普通对象:

function createRef() {
  return { current: null };
}

这个对象在整个生命周期内保持不变。

useImperativeHandle 做了什么

用自定义对象替换 ref.current

function useImperativeHandle(ref, createHandle, deps) {
  useEffect(() => {
    ref.current = createHandle();
    return () => { ref.current = null; };
  }, deps);
}

关键是:ref.current 不再指向 DOM 节点,而是指向 createHandle() 返回的对象。

流程图

graph TD
    A[父组件: useRef] --> B[ref 对象]
    B --> C[forwardRef 子组件]
    C --> D[内部 useRef]
    C --> E[useImperativeHandle]
    E --> F[ref.current = 自定义对象]
    F --> G[父组件调用方法]
    G --> H[实际执行内部方法]

为什么能生效

因为 ref 是可变的共享对象。父子组件访问的是同一个引用:

  1. 父组件创建 ref:const parentRef = useRef(null)
  2. 传给子组件
  3. 子组件调用 useImperativeHandle(parentRef, () => ({ ... }), [])
  4. 修改了 parentRef.current
  5. 父组件访问时得到的就是自定义对象

回调还是 ref ?怎么选

决策流程

flowchart TD
    A[需要子组件的数据/行为?] --> B{是主动还是被动?}
    B -->|被动: 子组件通知| C[用回调]
    B -->|主动: 父组件触发| D{回调能解决吗?}
    D -->|能| C
    D -->|不能| E[用 ref + forwardRef]

原则

优先用回调。只有回调实在搞不定时再用 ref。

原因:

  • 回调是声明式,代码意图更清晰
  • 数据流单向,易于调试
  • 子组件重构时影响范围小

举个🌰

可编辑单元格

const EditableCell = forwardRef(({ value, onSave }, ref) => {
  const [isEditing, setIsEditing] = useState(false);
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    startEdit: () => {
      setIsEditing(true);
      setTimeout(() => inputRef.current?.focus(), 0);
    },
    getValue: () => inputRef.current?.value
  }), []);

  if (isEditing) {
    return (
      <input
        ref={inputRef}
        defaultValue={value}
        onBlur={() => onSave(inputRef.current.value)}
      />
    );
  }

  return <span onClick={() => ref.current?.startEdit()}>{value}</span>;
});

表单验证

const ValidatedInput = forwardRef((props, ref) => {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    validate: () => {
      const value = inputRef.current.value;
      return value.length > 0 && value.includes('@');
    },
    focus: () => inputRef.current.focus()
  }), []);

  return <input ref={inputRef} {...props} />;
});

总结

问题解决方案
props 无法让父组件调用子组件ref
自定义组件无法接收 refforwardRef
不想暴露整个 DOMuseImperativeHandle

useImperativeHandle 的原理很简单:用自定义对象替换 ref.current。因为 ref 是共享的可变对象,父子访问同一个引用,所以能生效。

选回调还是 ref?记住三点:

  • 子组件主动通知 → 回调
  • 父组件主动触发 → 先尝试用回调重构,不行再用 ref
  • ref 是”逃生舱”,不是首选方案

延伸阅读