为什么需要 forwardRef 和 useImperativeHandle
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 是可变的共享对象。父子组件访问的是同一个引用:
- 父组件创建 ref:
const parentRef = useRef(null) - 传给子组件
- 子组件调用
useImperativeHandle(parentRef, () => ({ ... }), []) - 修改了 parentRef.current
- 父组件访问时得到的就是自定义对象
回调还是 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 |
| 自定义组件无法接收 ref | forwardRef |
| 不想暴露整个 DOM | useImperativeHandle |
useImperativeHandle 的原理很简单:用自定义对象替换 ref.current。因为 ref 是共享的可变对象,父子访问同一个引用,所以能生效。
选回调还是 ref?记住三点:
- 子组件主动通知 → 回调
- 父组件主动触发 → 先尝试用回调重构,不行再用 ref
- ref 是”逃生舱”,不是首选方案