前言
React.useImperativeHandle
不知道这个hook你们使用的频率如何,我反正是经常用在业务的Modal组件上。今天查看react新的官方文档发现,我这种使用方式居然是react官方不推荐的,这让我不禁思考起来“难道我一直都踩在陷阱里?”
再仔细翻阅文档,我并没有详细地看到官方告诉我,我这种用法到底有什么问题?单凭下面几句无法说服我,我反而觉得ModalRef.open()
这种写法更加优雅。
我的写法
在以下代码案例中,我举例一个Modal组件,通过useImperativeHandle向外expose了一个方法open。 该方法中接受一个传参id(而不是通过props传入),同时执行了request等副作用。
const _IssueModal = (props: IProps, ref: Ref<IRef>) => {
//变量声明、解构
const {} = props;
const { issueApi, roomTreeApi, issueListApi } = settingScreen$;
//组件状态
const uiIdRef = useRef('');
const deviceIdsRef = useRef<string[]>([]);
const [open, setOpen] = useState(false);
//网络IO
//数据转换
const options = transformOptions(roomTreeApi.data || []);
//逻辑处理函数
const handleReset = () => {
setOpen(false);
// 重置数据
uiIdRef.current = '';
deviceIdsRef.current = [];
};
const handleConfirm = async () => {
try {
await issueApi.request();
// 更新issue后,关闭模态框,刷新外面的List
// FIXME: 刷新List逻辑不应该放在Modal中执行
handleReset();
issueListApi.request();
} catch(e) {
// console.error(e);
}
};
//组件Effect
useImperativeHandle(ref, () => ({
open: (id: string) => {
if (!id) return;
uiIdRef.current = id;
setOpen(true);
// 初始化
roomTreeApi.request();
},
}));
//组件渲染
return (
<Modal
open={open}
title='主题资源包下发'
onCancel={handleReset}
onOk={handleConfirm}
destroyOnClose
maskClosable={false}
okButtonProps={{
disabled: !deviceIdsRef.current.length,
}}
>
<p>请选择要更新的设备</p>
<Cascader
className={styles.CascaderSelect}
options={options}
/>
</Modal>
);
};
//props类型定义
interface IProps {}
interface IRef {
open: (id: string) => void;
}
//prop-type定义,可选
const IssueModal = observer<IProps, IRef>(_IssueModal, { forwardRef: true });
export { IssueModal };
使用ModalRef.open
的好处
- 可读性好
我把相关的业务逻辑与state,都封装在一个单独的组件里,而不是把所有逻辑平铺到父组件。 特别是当前业务场景有多个Modal,如果采用传统写法的代码就像拉面条,每个open状态还需要单独命名,相当痛苦且不美观。
- 解耦
父组件不需要知道我这个Modal是干什么的,也不应该直接修改Modal的内部状态,只需要按照声明的接口IRef调用
open
就可以。
- 复用
上述例子中,
IssueModal
是个具有完整业务逻辑的组件,其他地方需要复用这段逻辑,直接引用Ref就可以。
// 使用React.ComponentRef 获取IRef类型,不要再写any了!!!
const issueModalRef = useRef<ComponentRef<typeof IssueModal>>(null);
// ...
const handleClick = (record) => {
issueModalRef.current?.open(record.id);
}
// ...
<IssueModal ref={issueModalRef} />
同道中人
google了一下,发现了不少人也和我一样是这样写Modal组件的。举个几个例子:
- Vue3 expose官方例子
- modal.open() - Imperative Component API in React
- Why imperative code using refs should be avoided?
后话
这篇文章并不是说我的写法是那么牛逼,而是解答我心中疑惑,“React官方推荐的一定是对吗?有没有更好的写法?当面对不同的业务场景我应该选择哪种写法会更好?”
感谢React官方提供那么棒的前端开源框架,只是基于OOP的开发思维&经验,面对我的业务场景时,我觉得使用useImperativeHandle
会更优雅。
NiceModalReact
感谢大家的评论&建议,受评论区提及的eBay/nice-modal-react的启发,给上述modal的写法来个更新。
在上述IssueModal
的例子中,新增了一个备注FIXME;相信有不少朋友和我一样犯下同一个错误。但这是不应该的,因为刷新List
并不是该Modal的职责。他应该是单一职责:UpdateIssueById
FIXME
在Modal内更新数据后需要刷新外侧的List数据,顺其自然地把List的更新请求逻辑在Modal中执行。
那么正确的做法应该把Update的结果告诉外侧,也就是issueModalRef.current.open
的调用方。如何告诉呢?
- 用props传入函数来触发?
- 用EventBus去监听?
- 其他方案?
在阅读eBay/nice-modal-react的源码,我才发现原来Promise
就是最优解。大家可以直接看看它的Demo ,简单来说就是把modal的open到close
视为一次异步任务,把modal中的执行结果在return给外侧。
更新后的代码
const issueModalRef = useRef<ComponentRef<typeof IssueModal>>(null);
// ...
const handleClick = async (record) => {
try {
const res = await issueModalRef.current?.open(record.id);
issueListApi.request();
} catch(e) {
// 用户主动关闭modal,没有更新issue,会触发catch
}
}
// ...
<IssueModal ref={issueModalRef} />
const _IssueModal = (props: IProps, ref: Ref<IRef>) => {
//变量声明、解构
const {} = props;
const { issueApi, roomTreeApi } = settingScreen$;
//组件状态
const uiIdRef = useRef('');
const deviceIdsRef = useRef<string[]>([]);
const [open, setOpen] = useState(false);
const PromiseRef = React.useRef<{
resolve: (value: TPromiseResolve) => void;
reject: (reason?: any) => void;
} | null>(null);
//网络IO
//数据转换
const options = transformOptions(roomTreeApi.data || []);
//逻辑处理函数
const handleReset = () => {
uiIdRef.current = '';
deviceIdsRef.current = [];
// do something to reset
PromiseRef.current?.reject();
setOpen(false);
};
const handleConfirm = async () => {
try {
await issueApi.request();
// 更新issue后,关闭模态框,返回更新结果
PromiseRef.current?.resolve();
handleReset();
} catch(e) {
// 更新issue失败,但模态框不关闭,让用户自行重试
// console.error(e);
}
};
//组件Effect
useImperativeHandle(ref, () => ({
open: (id: string) => {
if (!id) return Promise.reject();
uiIdRef.current = id;
roomTreeApi.request();
// do something to init
setOpen(true);
const promise = new Promise<TPromiseResolve>((resolve, reject) => {
PromiseRef.current = { resolve, reject };
});
return promise;
},
}));
//组件渲染
return (
<Modal
open={open}
title='主题资源包下发'
onCancel={handleReset}
onOk={handleConfirm}
destroyOnClose
maskClosable={false}
okButtonProps={{
disabled: !deviceIdsRef.current.length,
}}
>
<p>请选择要更新的设备</p>
<Cascader
className={styles.CascaderSelect}
options={options}
/>
</Modal>
);
};
//props类型定义
interface IProps {}
interface IRef {
open: (id: string) => Promise<TPromiseResolve>;
}
type TPromiseResolve = void;
//prop-type定义,可选
const IssueModal = observer<IProps, IRef>(_IssueModal, { forwardRef: true });
export { IssueModal };
为什么不直接用eBay/nice-modal-react
简单来说:维护Modal的方式不同,我不需要如此复杂的维护方式
尽管API调用上类似,但维护Modal却不同。
我的方式是自行声明Modal组件,同一个Modal组件可以创建多个相互独立的Modal,各自维护其销毁&创建。
而ebay会自动挂载在声明的Context.Provider下,并对每个Modal生成一个唯一id,收集起来。 在其内部称之为
register
,使用到了观察者模式
的设计模式。其好处在于:
- 统一管理Modal的生命周期
- 同一Modal组件全局只需要创建一次,可重复open。
- 可控制&监听某一个Modal的显隐