问题
对于项目中的弹窗、对话框等组件,传统的使用方式需要在模板中先声明一个组件,然后通过切换 props 来控制显示隐藏
只有一个弹窗时还好,但如果有多个弹窗时,情况就会变得复杂
const Comp = () => {
const [visible1, setVisible1] = useState(false);
const [visible2, setVisible2] = useState(false);
const [visible3, setVisible3] = useState(false);
return (
<>
<button onClick={() => setVisible1(true)}>弹窗1</button>
<Modal visible={visible1} />
<button onClick={() => setVisible2(true)}>弹窗2</button>
<Modal visible={visible2} />
<button onClick={() => setVisible3(true)}>弹窗3</button>
<Modal visible={visible3} />
</>
)
}
这样的写法虽然可以实现功能,但代码比较混乱,再加上组件中的其他功能模块,代码会变得更加难以维护
如果使用调用 showModal 函数的方式触发弹窗,相比之下代码会更加简洁。因此需要命令式组件
在网上搜索了一下,发现大多都是 react 中使用 context API,vue 中使用依赖注入的方式实现命令式组件
这种方式确实是最好的选择,但我还是想分享一下另一种实现方式
React 中的命令式组件
核心思想是通过 createRoot 方法创建命令式组件
封装核心方法 createModal,该方法接收一个 React 组件,并返回一个函数,该函数用于卸载组件
import { createRoot } from "react-dom/client";
import { type ReactNode } from "react";
import type { ModalProps } from "antd";
import ModalContainer from "./components/ModalContainer";
const createModal = (modal: ReactNode) => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
root.render(modal);
return () => {
root.unmount();
document.body.removeChild(container);
};
};
export const showModal = (props: ModalProps) => {
const afterClose = props.afterClose;
const onUnmount = createModal(
<ModalContainer {...props} afterClose={() => {
afterClose?.();
// 异步卸载 root 确保当前渲染已经完成
Promise.resolve().then(() => onUnmount());
}} />
);
return onUnmount;
};
导出 showModal 函数,使用二次封装 antd 的 Modal 组件为例, ModalContainer 作为容器
import type { ModalProps } from "antd";
import { Modal } from "antd";
import type { FC } from "react";
import { ModalProvider, useModalContext } from "@/context/modalContext";
/**
* 通过 ModalProvider 包裹 Modal 组件,向子组件中暴露 closeModal 方法用于关闭 Modal
*/
const ModalContainer: FC<ModalProps> = (props) => {
const { visible, closeModal } = useModalContext();
return (
<Modal
{...props}
open={visible}
onOk={(e) => {
closeModal();
props.onOk?.(e);
}}
onCancel={(e) => {
closeModal();
props.onCancel?.(e);
}}
/>
)
}
const ModalContainerWithProvider: FC<ModalProps> = (props) => {
return (
<ModalProvider>
<ModalContainer {...props} />
</ModalProvider>
);
};
export default ModalContainerWithProvider;
其中使用 context API,向子组件暴露 closeModal 方法,用于关闭弹窗
import { createContext, useContext, useState, type FC, type ReactNode } from "react";
// 用于命令式弹窗的上下文
const ModalContext = createContext<{
visible: boolean;
closeModal: () => void;
} | null>(null);
export const ModalProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [visible, setVisible] = useState(true);
const closeModal = () => {
setVisible(false);
};
return (
<ModalContext.Provider value={{ closeModal, visible }}>
{children}
</ModalContext.Provider>
);
}
export const useModalContext = () => {
const context = useContext(ModalContext);
if (!context) {
throw new Error("useModalContext must be used within a ModalProvider");
}
return context;
};
最终只需要通过 showModal 函数触发弹窗,通过 closeModal 方法关闭弹窗,使用起来非常简洁
const showUserModal = () => {
showModal({
children: <UserForm />,
footer: null,
title: "注册/登录",
});
};
重要的是并非只适用于简单的弹窗,也可以用于包含复杂表单的弹窗
当然,缺陷也比较明显,就是脱离了当前的 React 应用,也就没法在传入的组件中使用基于 context API 的功能
例如,状态管理库 redux,上面的例子中使用的是 zustand,并不依赖于 context API
当然也不是没有解决方案,可以在调用 showModal 传入组件时通过 props 传入需要的数据,定义必要的回调函数。例如这样:
const showUserModal = () => {
showModal({
children: <UserForm store={store} onOk={onOk} onCancel={onCancel} />,
footer: null,
title: "注册/登录",
});
};
不过这样一来,弹窗中的逻辑就会过于分离,并且杂糅在使用组件中,不利于维护
其实 antd 中的 message 组件也包含了这样的实现逻辑,message 组件拥有多种使用方法
- 直接使用
import { message } from "antd";
message.success("成功");
- hooks
const [messageApi, contextHolder] = message.useMessage();
- App
import { App } from "antd";
const { message } = App.useApp();
其中第一种方法就是使用了上述实现逻辑
Vue 中的命令式组件
核心思想是通过 createApp 方法创建命令式组件
对于方法的封装逻辑和 React 中区别并不大,要做完全通用的只能使用传统方案,重要的是根据使用场景灵活封装,有时候能实现一些优雅的使用场景,下面是一些和具体场景绑定的功能实现
这是一个简单的全局提示组件
import Message from './Message.vue';
import { createApp } from 'vue';
function createContainer() {
const container = document.createElement('div');
document.body.appendChild(container);
return container;
}
/**
* @param {Object} options
* @param {String} options.type
* @param {String} options.content
* @param {Number} options.duration
*/
export function showMessage(options = {}) {
const app = createApp(Message, options);
const container = createContainer();
app.mount(container);
setTimeout(
() => {
app.unmount();
document.body.removeChild(container);
},
options.duration + 300 || 3300
);
}
下面是一个低码项目中的文件选择器,用于项目的编辑器部分
import FileManager from "./FileManager.vue";
import { createApp, ref } from "vue";
import type { App } from "vue";
function createContainer() {
const div = document.createElement("div");
document.body.appendChild(div);
return {
container: div,
unmount: () => {
document.body.removeChild(div);
},
};
}
let app: App | null = null;
let container: { container: HTMLDivElement; unmount: () => void } | null = null;
const isShow = ref(false);
let resolveCallback: (value: any) => void;
/**
* 挂载文件管理器
*/
export function mount() {
container = createContainer();
app = createApp(FileManager, {
isShow,
onClose: (fileUrl?: string) => {
isShow.value = false;
resolveCallback(fileUrl);
},
});
app.mount(container.container);
}
/**
* 卸载文件管理器
*/
export function unmount() {
if (!app) return;
app.unmount();
container?.unmount();
app = null;
container = null;
}
/**
* 选择文件
* @return Promise<string | undefined>
*/
export function selectFile() {
return new Promise((resolve, reject) => {
if (!app) return reject("FileManager not mounted");
isShow.value = true;
resolveCallback = resolve;
});
}
通过返回一个 Promise,等待用户选择,通过 await 关键字可以直接拿到选择结果。并且提供了 mount 和 unmount 方法,可以在需要的时候避免频繁的销毁/挂载。
const handleSelect = async () => {
const res = await selectFile();
// ...
}
当然 Vue 中也可以使用 tsx 语法编写组件,更适用于灵活场景和代码聚合
写在最后
总体来说,这种方法编写的代码还算优雅,当然缺点也很明显。通过灵活封装能在特定场景实现意想不到的效果。分享一下,欢迎大家讨论