React forwardRef相关总结
一句话概括:React 使用 forwardRef 完成 ref 的转发。
一、React forwardRef 简介
没有使用forwardRef
时,父组件传入子组件ref
属性,此时ref
指向的是子组件本身。如下所示:
class Child extends React.Component {
render() {
return (
<div>
<button>点我</button>
</div>
);
}
}
function App() {
const child = useRef<any>();
useEffect(() => {
setTimeout(() => {
console.log(child);
}, 2000);
}, []);
return (
<div styleName="container">
<Child ref={child} />
</div>
);
}
此时控制台的截图如下所示:
可是如果我想让child
指向的是Child
的button
呢?此时只能拓展Child
的props
,新增一个字段如buttonRef
,如下所示:
interface IProps {
buttonRef: any;
}
class Child extends React.Component<IProps> {
render() {
return (
<div>
<button ref={this.props.buttonRef}>点我</button>
</div>
);
}
}
function App() {
const child = useRef<any>();
useEffect(() => {
setTimeout(() => {
console.log(child);
}, 2000);
}, []);
return (
<div styleName="container">
<Child buttonRef={child} />
</div>
);
}
此时child指向的是button这一dom对象。
可以看到在该场景中存在的问题:子组件需要增加 buttonRef
字段,父组件需要承担相应的心智负担。
所以 React 提供了 forwardRef
,用于将 ref 转发。这样子组件在提供内部的 dom 时,不用扩充额外的 ref 字段
修改后代码如下所示:
const Child = forwardRef((props: any, ref: any) => {
return (
<div>
<button ref={ref}>点我</button>
</div>
);
});
function App() {
const child = useRef<any>();
useEffect(() => {
setTimeout(() => {
console.log(child);
}, 2000);
}, []);
return (
<div styleName="container">
<Child ref={child} />
</div>
);
}
修改后 App 组件不用承担额外的心智负担,还是像之前那样使用 ref 即可。
二、高阶组件中使用 forwardRef 转发组件 Ref
该例子为官方文档中的例子,阅读过的读者可直接跳到三。
现有一 Hoc 为打印组件 props:
function logProps(WrappedComponent) {
class LogProps extends React.Component {
componentDidUpdate(prevProps) {
console.log('old props:', prevProps);
console.log('new props:', this.props);
}
render() {
return <WrappedComponent {...this.props} />;
}
}
return LogProps;
}
当其作用于子组件时,因为中间隔了一层,ref 就会传递失败,最终导致父组件调用ref.current.xxx()时失败:
import Button from './Button';
const LoggedButton = logProps(Button);
const ref = React.createRef();
// LoggedButton 组件是高阶组件(HOC)LogProps。
// 尽管渲染结果将是一样的,
// 但我们的 ref 将指向 LogProps 而不是内部的 Button 组件!
// 这意味着我们不能调用例如 ref.current.xxx() 这样的方法
<LoggedButton label="Click Me" handleClick={handleClick} ref={ref} />;
此时我们就需要在 hoc 中用 forwardRef 再包一层,转发 ref:
function logProps(WrappedComponent) {
class LogProps extends React.Component {
componentDidUpdate(prevProps) {
console.log('old props:', prevProps);
console.log('new props:', this.props);
}
render() {
const { componentRef, ...rest } = this.props;
return <WrappedComponent {...rest} ref={this.props.componentRef} />;
}
}
return forwardRef((props, ref) => {
return <LogProps {...props} componentRef={ref} />;
});
}
三、拓展:使用forwardRef和useImperativeHandle限制父组件调用子组件的Api
假设现有一开发人员开发了一个控制某状态切换的按钮组件Button.tsx
,组件代码如下所示:
import React from 'react';
export enum Status {
On = 'on',
Off = 'off',
}
interface IProps {
onChange: (status: Status) => void;
}
interface IState {
status: Status;
}
class Button extends React.Component<IProps, IState> {
state = {
status: Status.Off,
};
private logStatus = () => {
console.log(this.state.status);
};
onToggleStatus = () => {
this.setState(
(prevState) => ({
status: prevState.status === Status.Off ? Status.On : Status.Off,
}),
() => {
this.logStatus();
this.props.onChange(this.state.status);
}
);
};
render() {
return (
<div>
<button onClick={this.onToggleStatus}>status: {this.state.status}</button>
</div>
);
}
}
export default Button;
演示效果如下所示,每次点击切换内部状态:
Button组件提供了onChange
回调,外部组件可以传入onChange
方法获取实时的status
,Button内部则通过onToggleStatus
控制状态。
如果现在另一个开发人员开发外部组件时,想要实现在外部实现第二个按钮实时控制和同步显示Button的状态。此时他已经可以通过onChange
实时同步状态,而从外部修改Button状态则一般有两种方式:
- 修改Button组件为纯函数组件,将其状态和修改状态的方法提升至父组件或者状态管理工具中。
- 通过ref拿到该组件,通过
ref.current.onToggleStatus()
的方式修改子组件状态。
这两种方式各有利弊,使用者需要根据实际场景自行选择。下面我将针对第二种方案,讨论如何更好的使用。
首先,先列出父组件使用ref的代码:
function App() {
const [buttonStatus, setButtonStatus] = useState<Status>();
const ref = useRef<any>();
const onClick = useCallback(() => {
// 更新内部状态
ref.current.onToggleStatus();
// 更新外部状态
setButtonStatus(() => ref.current.state.status);
}, []);
// 首次渲染时同步状态
useEffect(() => {
setButtonStatus(() => ref.current.state.status);
}, []);
return (
<div styleName="container">
<Button ref={ref} onChange={setButtonStatus} />
<button onClick={onClick}>控制和显示👆的按钮状态:{buttonStatus}</button>
</div>
);
}
父组件通过ref.current.state.status
读取Button状态,通过onChange
钩子实时同步状态,最终实现了下图的效果:
到这里,实际上功能已经开发完毕了。但是,本着探索的原则,我们回顾一下父组件的代码和调用方式,会发现这种方式存在以下几个问题:
- 子组件Button的接口完全被暴露在外,如果子组件是一个多组件复用的、状态集成到状态管理工具中的组件,父组件随意调用是很有可能造成风险的。
- 子组件Button的开发者在实际的开发中,并没有设计供外部主动调用的接口的开发意识。将Button组件当做糕点师的话,
onChange
是响应式的api风格,是我做多少糕点你吃多少,我什么时候做,你什么时候吃;而供外部主动调用的接口则是命令式的api风格,更像是外部主动点餐,你点多少我做多少,你什么时候点,我什么时候做给你吃。
面对以上问题,我们可以采用如下的优化方式:使用forwardRef
加上useImperativeHandle
封装一个HOC,动态地补充供外部主动调用的接口,让Button组件无感知。
代码如下:
import React, { useImperativeHandle, useRef } from 'react';
function buttonDecorator(Component: any) {
const WrappedComponent: React.FC<any> = (props) => {
const childRef = useRef<any>();
const { parentRef, ...rest } = props;
// 封装供外部主动调用的接口
useImperativeHandle(parentRef, () => ({
toggleStatus: () => {
childRef.current.onToggleStatus();
},
getStatus: () => {
return childRef?.current?.state?.status;
},
}));
return <Component {...rest} ref={childRef} />;
};
return React.forwardRef<any, any>((props, ref) => {
return <WrappedComponent {...props} parentRef={ref} />;
});
}
export default buttonDecorator;
可以看到hoc包裹Button后仅提供toggleStatus
和getStatus
方法。该hoc通过useImperativeHandle
给被转发的parentRef
注入公共api,api中通过childRef
实现对子组件的调用。所以父组件此时调用ref.current.state
访问状态,就会报错:
对于上述的问题一(多组件复用可能造成副作用),开发者可以自行使用useImperativeHandle
定义api,控制对状态管理工具的副作用范围,对于问题二(组件内部的api更偏向于onXX的响应式风格,外部调用则更偏向于命令式的风格),开发者仅仅二次命名api,就可以让api从外部看起来很命令式,如用toggleStatus
替代onToggleStatus
。父组件对应需要修改:
const onClick = useCallback(() => {
// 更新内部状态
ref.current.toggleStatus();
// 更新外部状态
setButtonStatus(() => ref.current.getStatus());
}, []);
// 首次渲染时同步状态
useEffect(() => {
setButtonStatus(() => ref.current.getStatus());
}, []);
四、总结
本文主要是介绍了forwardRef
的意义、forwardRef
在hoc中是如何转发Ref的、forwardRef
的一种实际业务场景。关于forwardRef
的更多使用场景需要读者自行去探索,希望本文能给读者一些启发。