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