引言
在 react 中使用全局 Modal 一直是一件比较麻烦的事情。一般我们都是使用 JSX 标签定义Modal组件。
但接下来的问题是,"我们应该在哪里声明标签?"
-
最常见的选择是在使用 Modal 的任何地方编写 Modal 标签。
但以声明方式使用 Modal 组件不仅仅涉及 JSX 标签,还涉及维护Modal的状态,例如 Modal 的可见性、参数等。 每一次编写组件标签就意味着需要可能要多管理一次组件的状态。这是一件比较麻烦的事情。
-
另一种选择是将 Modal 提升到公共的父组件上,但是通过父组件维护Modal依旧是非常麻烦。
比如你需要将 setOpen 传递到需要显示或隐藏 Modal 的子组件中,谁知道会有多少层?
react-nice-modal
介绍
这是一个用于以自然的方式为 React 管理 Modal 组件。 它使用 react context 来全局保存 Modal 状态,以便可以通过 Modal 组件或 id 轻松显示/隐藏 Modal。
基本用法
- 使用 Provider 包裹组件
tsx
import NiceModal from '@ebay/nice-modal-react';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<NiceModal.Provider>
<App />
</NiceModal.Provider>
</React.StrictMode>
);
- 创建一个 Nice Modal
tsx
import { Modal } from 'antd';
import NiceModal, { useModal } from '@ebay/nice-modal-react';
export default NiceModal.create(({ name }) => {
// Use a hook to manage the modal state
const modal = useModal();
return (
<Modal
title="Hello Antd"
onOk={() => modal.hide()}
visible={modal.visible}
onCancel={() => modal.hide()}
afterClose={() => modal.remove()}
>
Hello {name}!
</Modal>
);
});
- 然后就可以通过如下代码在任意组件显示/隐藏 Modal 组件
tsx
import NiceModal from '@ebay/nice-modal-react';
import MyAntdModal from './my-antd-modal'; // created by above code
function App() {
// 或者
// const modal = useModal(MyAntdModal);
const showAntdModal = () => {
// Show a modal with arguments passed to the component as props
NiceModal.show(MyAntdModal, { name: 'Nate' })
// 或者
// modal.show({ name: 'Nate1' })
};
return (
<div className="app">
<h1>Nice Modal Examples</h1>
<div className="demo-buttons">
<button onClick={showAntdModal}>Antd Modal</button>
</div>
</div>
);
}
经过 NiceModal
的封装,好处显而易见:
- 调用过程干净优雅
- 组件依旧存在于上下文中(可以自定义位置,默认在
Provider
下) show/hide
方法返回值为Promise
,方便链式调用(通过useModal().resolve(val)
等方法改变Promise
状态)
更多用法参考 NiceModal 官方文档
源码分析
- 先来看几个数据结构:
ts
export interface NiceModalState {
id: string;
// Modal Props,可通过 show()方法或者组件标签传入
args?: Record<string, unknown>;
visible?: boolean;
delayVisible?: boolean;
keepMounted?: boolean;
}
// context,使用useReducer创建
export interface NiceModalStore {
[key: string]: NiceModalState;
}
// show()/hide()方法返回的promise及相应的变更方法
interface NiceModalCallbacks {
[modalId: string]: {
resolve: (args: unknown) => void;
reject: (args: unknown) => void;
promise: Promise<unknown>;
};
}
// 所有已注册的Modal组件和props
const MODAL_REGISTRY: {
[id: string]: {
comp: React.FC<any>;
props?: Record<string, unknown>;
};
} = {};
const ALREADY_MOUNTED = {}; // Record<string, boolean>
// 修改context的操作函数
let dispatch: React.Dispatch<NiceModalAction>
- register / unregister 比较简单,注册和清除 Modal 组件
ts
export const register = <T extends React.FC<any>>(
id: string,
comp: T,
props?: Partial<NiceModalArgs<T>>,
): void => {
if (!MODAL_REGISTRY[id]) {
MODAL_REGISTRY[id] = { comp, props };
} else {
MODAL_REGISTRY[id].props = props;
}
};
export const unregister = (id: string): void => {
delete MODAL_REGISTRY[id];
};
- Provider
tsx
const InnerContextProvider: React.FC = ({ children }) => {
const arr = useReducer(reducer, initialState);
const modals = arr[0];
dispatch = arr[1];
return (
<NiceModalContext.Provider value={modals}>
{children}
<NiceModalPlaceholder />{/* 该占位组件使用 modals 去创建组件 */}
</NiceModalContext.Provider>
);
};
// The placeholder component is used to auto render modals when call modal.show()
// When modal.show() is called, it means there've been modal info
const NiceModalPlaceholder: React.FC = () => {
const modals = useContext(NiceModalContext);
const visibleModalIds = Object.keys(modals).filter((id) => !!modals[id]);
const toRender = visibleModalIds
.filter((id) => MODAL_REGISTRY[id])
.map((id) => ({
id,
...MODAL_REGISTRY[id],
}));
// 所有渲染的Modal都是从context中获取的,渲染的是create方法返回的组件
// props是从MODAL_REGISTRY获取的,MODAL_REGISTRY仅仅通过register方法修改
// 而register方法虽然提供了修改props的方式,但是NiceModal内部仅在初次注册的时候添加props
return (
<>
{toRender.map((t) => (
<t.comp key={t.id} id={t.id} {...t.props} />
))}
</>
);
};
- create 方法
tsx
export const create = <P extends {}>(
Comp: React.ComponentType<P>,
): React.FC<P & NiceModalHocProps> => {
return ({ defaultVisible, keepMounted, id, ...props }) => {
// 这里的id是怎么来的?
// NiceModalPlaceholder里面传入的 **这种方式有个坑,先注意下**
// 或者是你通过使用jsx标签时传入
const { args, show } = useModal(id);
// If there's modal state, then should mount it.
const modals = useContext(NiceModalContext);
const shouldMount = !!modals[id];
useEffect(() => {
// If defaultVisible, show it after mounted.
if (defaultVisible) {
show();
}
ALREADY_MOUNTED[id] = true;
return () => {
delete ALREADY_MOUNTED[id];
};
}, [id, show, defaultVisible]);
useEffect(() => {
if (keepMounted) setFlags(id, { keepMounted: true });
}, [id, keepMounted]);
const delayVisible = modals[id]?.delayVisible;
// If modal.show is called
// 1. If modal was mounted, should make it visible directly
// 2. If modal has not been mounted, should mount it first, then make it visible
// 注意这里的mount是指修改ALREADY_MOUNTED
useEffect(() => {
if (delayVisible) {
// delayVisible: false => true, it means the modal.show() is called, should show it.
show(args);
}
}, [delayVisible, args, show]);
if (!shouldMount) return null;
return (
{/* 通过NiceModalIdContext传递Modal id,在Comp组件的useModal中使用 */}
<NiceModalIdContext.Provider value={id}>
{/* 透传props和useModal及show传入的args */}
<Comp {...(props as P)} {...args} />
</NiceModalIdContext.Provider>
);
};
};
- useModal
typescript
export function useModal(): NiceModalHandler;
export function useModal(modal: string, args?: Record<string, unknown>): NiceModalHandler;
export function useModal<C extends any, P extends Partial<NiceModalArgs<React.FC<C>>>>(
modal: React.FC<C>,
args?: P,
): Omit<NiceModalHandler, 'show'> & {
show: (args?: P) => Promise<unknown>;
};
export function useModal(modal?: any, args?: any): any {
const modals = useContext(NiceModalContext);
const contextModalId = useContext(NiceModalIdContext);
let modalId: string | null = null;
const isUseComponent = modal && typeof modal !== 'string';
if (!modal) {
modalId = contextModalId;
} else {
// getModalId 会根据你给的类型,如果是 string, 那么直接返回,
// 如果是 modal,那么会帮你生成一个 id
modalId = getModalId(modal);
}
// Only if contextModalId doesn't exist
if (!modalId) throw new Error('No modal id found in NiceModal.useModal.');
const mid = modalId as string;
// If use a component directly, register it.
useEffect(() => {
if (isUseComponent && !MODAL_REGISTRY[mid]) {
// 注意,这里向MODAL_REGISTRY注册了Modal,同时传入了args
register(mid, modal as React.FC, args);
}
}, [isUseComponent, mid, modal, args]);
const modalInfo = modals[mid];
const showCallback = useCallback((args?: Record<string, unknown>) => show(mid, args), [mid]);
const hideCallback = useCallback(() => hide(mid), [mid]);
const removeCallback = useCallback(() => remove(mid), [mid]);
const resolveCallback = useCallback(
(args?: unknown) => {
modalCallbacks[mid]?.resolve(args);
delete modalCallbacks[mid];
},
[mid],
);
const rejectCallback = useCallback(
(args?: unknown) => {
modalCallbacks[mid]?.reject(args);
delete modalCallbacks[mid];
},
[mid],
);
const resolveHide = useCallback(
(args?: unknown) => {
hideModalCallbacks[mid]?.resolve(args);
delete hideModalCallbacks[mid];
},
[mid],
);
return useMemo(
() => ({
id: mid,
// 这个args是从context中获取
args: modalInfo?.args,
visible: !!modalInfo?.visible,
keepMounted: !!modalInfo?.keepMounted,
show: showCallback,
hide: hideCallback,
remove: removeCallback,
resolve: resolveCallback,
reject: rejectCallback,
resolveHide,
}),
[
mid,
modalInfo?.args,
modalInfo?.visible,
modalInfo?.keepMounted,
showCallback,
hideCallback,
removeCallback,
resolveCallback,
rejectCallback,
resolveHide,
],
);
}
- show/hide方法,比较简单,通过reducer更新context,同时保存和清除show/hide方法返回值及变更方法
ts
export function show<T extends any, C extends any, P extends Partial<NiceModalArgs<React.FC<C>>>>(
modal: React.FC<C>,
args?: P,
): Promise<T>;
export function show<T extends any>(modal: string, args?: Record<string, unknown>): Promise<T>;
export function show<T extends any, P extends any>(modal: string, args: P): Promise<T>;
export function show(
modal: React.FC<any> | string,
args?: NiceModalArgs<React.FC<any>> | Record<string, unknown>,
) {
const modalId = getModalId(modal);
if (typeof modal !== 'string' && !MODAL_REGISTRY[modalId]) {
register(modalId, modal as React.FC);
}
// 通过reducer更新context
dispatch(showModal(modalId, args));
if (!modalCallbacks[modalId]) {
// `!` tell ts that theResolve will be written before it is used
let theResolve!: (args?: unknown) => void;
let theReject!: (args?: unknown) => void;
const promise = new Promise((resolve, reject) => {
theResolve = resolve;
theReject = reject;
});
modalCallbacks[modalId] = {
resolve: theResolve,
reject: theReject,
promise,
};
}
return modalCallbacks[modalId].promise;
}
export function hide<T>(modal: string | React.FC<any>): Promise<T>;
export function hide(modal: string | React.FC<any>) {
const modalId = getModalId(modal);
dispatch(hideModal(modalId));
// Should also delete the callback for modal.resolve
delete modalCallbacks[modalId];
if (!hideModalCallbacks[modalId]) {
let theResolve!: (args?: unknown) => void;
let theReject!: (args?: unknown) => void;
const promise = new Promise((resolve, reject) => {
theResolve = resolve;
theReject = reject;
});
hideModalCallbacks[modalId] = {
resolve: theResolve,
reject: theReject,
promise,
};
}
return hideModalCallbacks[modalId].promise;
}
主要的源码就是这些,其中我觉得比较复杂的地方是 Modal 组件的 props 的控制
有四种形式给 Modal 组件提供props
- 通过show方法
- 通过jsx的方式使用create返回的组件
- 通过ModalHolder组件
- 通过useModal
tsx
export const ModalHolder: React.FC<Record<string, unknown>> = ({
modal,
handler = {},
...restProps
}: {
modal: string | React.FC<any>;
handler: any;
[key: string]: any;
}) => {
const mid = useMemo(() => getUid(), []);
const ModalComp = typeof modal === 'string' ? MODAL_REGISTRY[modal]?.comp : modal;
handler.show = useCallback((args: any) => show(mid, args), [mid]);
handler.hide = useCallback(() => hide(mid), [mid]);
return <ModalComp id={mid} {...restProps} />;
};
记得前面说的坑吗?跟使用useModal传递props有关
ts
const modal = useModal(MyAntdModal, { name: 'aaaa' });
// 然后,通过modal.show()就可以渲染出之前的MyAntdModal
modal.show()
但是,如果需要传递的 props 中有 id,那么,你会发现 Modal 无法打开了,为什么呢?
ini
const modal = useModal(MyAntdModal, { name: 'aaaa', id: 'aaa' });
原因在 useModal hook 中
ts
useEffect(() => {
if (isUseComponent && !MODAL_REGISTRY[mid]) {
// 注意,这里向MODAL_REGISTRY注册了Modal,同时传入了args
register(mid, modal as React.FC, args);
}
}, [isUseComponent, mid, modal, args]);
那么在 NiceModalPlaceholder 中渲染 Modal 时,t.props 将 id 覆盖了,就会导致 t.comp 里面的 useModal 无法获得 Modal 组件 (id 不对)
ts
// ...
const toRender = visibleModalIds
.filter((id) => MODAL_REGISTRY[id])
.map((id) => ({
id,
...MODAL_REGISTRY[id],
}));
return (
<>
{toRender.map((t) => (
<t.comp key={t.id} id={t.id} {...t.props} />
))}
</>
);
那为什么通过 show 方法传入的 props 可以有 id 属性呢?
不管是通过 modal.show
还是 NiceModal.show
调用,最终调用的都是 NiceModal.show(ModalOrId, args)
,没有涉及获取 Modal
总结
学习了 NiceModal 的实现原理, 有的时候,跳出固有的思维逻辑,可能会发现新的天地
下面说下大概流程操作。
- Provide 挂载,内部创建 dispatch
- create 创建 Modal 组件
- register(注册 id)
- 页面中使用 NiceModal.show(id)调用
- 使用 dispatch 修改该 id 的属性
- dispatch 通知 Provider 内部的组件渲染