Back to Blog
· 4 min read

为什么需要 forwardRef 和 useImperativeHandle

react

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

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

单向数据流

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

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

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

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

父组件没法直接调用子组件的方法。

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

ref

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

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 里是个”后门”。

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();

一旦父组件获得了完全的控制权,在代码实现上很容易出现耦合的问题,也可能带来安全问题,比如父组件绕过子组件的业务逻辑直接使用数据,未来子组件重构也可能破坏父组件。

因而需要自定义 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 的本质说起,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. 父组件访问时得到的就是自定义对象

应用场景

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

优先用回调,只有回调实在搞不定时再用 ref,因为

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

Demo

可编辑单元格

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 是”逃生舱”,不是首选方案

延伸阅读