简介

git仓库:https://github.com/eBay/nice-modal-react

nice-modal-react是ebay开源的一个非常好用的React弹窗管理库,适用于MUI,Antd等热门的组件库

源码分析

注册组件

要使用这个库,首先需要将应用用<NiceModal.Provider> 包裹

1
2
3
4
5
6
7
8
9
import NiceModal from '@ebay/nice-modal-react';
ReactDOM.render(
<React.StrictMode>
<NiceModal.Provider>
<App />
</NiceModal.Provider>
</React.StrictMode>,
document.getElementById('root'),
);

下面是<NiceModal.Provider> 的源码部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
const initialState: NiceModalStore = {};
let dispatch: React.Dispatch<NiceModalAction> = () => {
throw new Error('No dispatch method detected, did you embed your app with NiceModal.Provider?');
};

// 其他代码...

const InnerContextProvider: React.FC = ({ children }) => {
// 默认用useReducer管理弹窗modals的数据
const arr = useReducer(reducer, initialState);
const modals = arr[0];
// 用useReducer的dispatch函数赋值
dispatch = arr[1];

// 将modals传入Provider,因为这个是包裹在应用外层的,所以在应用里各个地方都可以用useContext()获取
return (
<NiceModalContext.Provider value={modals}>
{children}
<NiceModalPlaceholder />
</NiceModalContext.Provider>
);
};

export const Provider: React.FC<Record<string, unknown>> = ({
children,
dispatch: givenDispatch,
modals: givenModals,
}: {
children: ReactNode;
dispatch?: React.Dispatch<NiceModalAction>;
modals?: NiceModalStore;
}) => {
if (!givenDispatch || !givenModals) {
return <InnerContextProvider>{children}</InnerContextProvider>;
}

// 下面是传入自定义dispatch或modals的处理, 暂时忽略
dispatch = givenDispatch;
return (
<NiceModalContext.Provider value={givenModals}>
{children}
<NiceModalPlaceholder />
</NiceModalContext.Provider>
);
};

这个Provider主要做了两件事:

  • 设置状态管理函数,如果没传入自定义的,就使用useReducer。管理的是所有弹窗的数据
  • 添加一个弹窗的占位<NiceModalPlaceholder/>

<NiceModalContext.Provider>的value更新时,触发子元素的更新,当然包括<NiceModalPlaceholder>,这样弹窗才会更新显影状态

那么看看modals是怎么被更新的,我们去看看reducer函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const reducer = (
state: NiceModalStore = initialState,
action: NiceModalAction,
): NiceModalStore => {
switch (action.type) {
case 'nice-modal/show': {
const { modalId, args } = action.payload;
return {
...state,
[modalId]: {
...state[modalId], // 如果已有就修改
id: modalId,
args,
visible: !!ALREADY_MOUNTED[modalId],
delayVisible: !ALREADY_MOUNTED[modalId],
},
};
}
// 其他的暂时不看了...
}
};

看来当dispatch触发type为nice-modal/show的action时,会往modals里添加或修改一项弹窗数据

看下源码里,发现show()函数会dispatch这个action,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function showModal(modalId: string, args?: Record<string, unknown>): NiceModalAction {
return {
type: 'nice-modal/show',
payload: {
modalId,
args,
},
};
}

export function show(
// 这里弹窗既可以传入组件,也可以传入string类型,即弹窗的id
modal: React.FC<any> | string,
args?: NiceModalArgs<React.FC<any>> | Record<string, unknown>,
) {
// 获取弹窗的id(如果没有会设置一个)
const modalId = getModalId(modal);

// 如果参数是个组件并且没有注册过的话,会注册一下弹窗。
// 如果modal是string类型的话,一般是已经注册过了
// 如果传入的是没注册过的id呢?会将其过滤掉,在后文中会提到
if (typeof modal !== 'string' && !MODAL_REGISTRY[modalId]) {
register(modalId, modal as React.FC);
}

// 添加弹窗到modals里,因为上一步注册了弹窗,所以这一步只用传id即可
dispatch(showModal(modalId, args));

// 下面暂时不看...
}

register函数就简单了,就是将传入的数据记录在对象里。
所有注册过的弹窗都会被渲染在<NiceModalPlaceholder/>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const MODAL_REGISTRY: {
[id: string]: {
comp: React.FC<any>;
props?: Record<string, unknown>;
};
} = {};

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;
}
};

来看看<NiceModalPlaceholder/>做了什么
<NiceModalContext.Provider>的value更新时,触发子元素的更新,当然包括<NiceModalPlaceholder>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const NiceModalPlaceholder: React.FC = () => {
// 获取到所有弹窗的信息
const modals = useContext(NiceModalContext);

const visibleModalIds = Object.keys(modals).filter((id) => !!modals[id]);
visibleModalIds.forEach((id) => {
if (!MODAL_REGISTRY[id] && !ALREADY_MOUNTED[id]) {
console.warn(
`No modal found for id: ${id}. Please check the id or if it is registered or declared via JSX.`,
);
return;
}
});

// 这里会过滤掉未注册的弹窗,因此前文提到的传一个未注册的id会在这里兜底处理掉
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} />
))}
</>
);
};

创建弹窗组件

当然,在这里渲染的弹窗组件也并非随便一个组件就行,据官方的文档,这里的弹窗组件需要用create方法创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Modal } from 'antd';
import NiceModal, { useModal } from '@ebay/nice-modal-react';

export default NiceModal.create(({ name }: { name: string }) => {
// 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>
);
});

下面是create函数的源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
export const create = <P extends {}>(
Comp: React.ComponentType<P>,
): React.FC<P & NiceModalHocProps> => {
return ({ defaultVisible, keepMounted, id, ...props }) => {
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
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.Provider value={id}>
<Comp {...(props as P)} {...args} />
</NiceModalIdContext.Provider>
);
};
};

这部分稍微复杂,我们来分段看一下

首先从整体来说,create函数的作用就是在传入的组件外层套了个Provider,并且给组件添加了一些额外的props

1
2
3
4
5
6
7
8
9
10
11
12
13
export const create = <P extends {}>(
Comp: React.ComponentType<P>,
): React.FC<P & NiceModalHocProps> => {
return ({ defaultVisible, keepMounted, id, ...props }) => {
// ...

return (
<NiceModalIdContext.Provider value={id}>
<Comp {...(props as P)} {...args} />
</NiceModalIdContext.Provider>
);
};
};

注意到这里从外部接收id这个props并且将其用上下文的形式传给子组件,那这个id是从哪来的?

实际上就是从<NiceModalPlaceholder>传入的,其他的props也是。create函数创建的组件会通过register函数注册最后在<NiceModalPlaceholder>渲染并传入一些props

1
2
3
4
5
6
7
8
9
10
11
const NiceModalPlaceholder: React.FC = () => {
// ...

return (
<>
{toRender.map((t) => (
<t.comp key={t.id} id={t.id} {...t.props} />
))}
</>
);
};

再看看主体部分,第一行用useModal来获取了一些方法

1
const { args, show } = useModal(id);

那这个useModal又做了什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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 {
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]) {
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: 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,
],
);
}

上面是useModal的源码,因为useModal不仅提供给create函数使用,所以参数和判断逻辑较多。
我们先看create函数里调用的useModal的用法,即不传任何参数,下面是精简后的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export function useModal(modal?: any, args?: any): any {
const modals = useContext(NiceModalContext);
// 从上下文获取弹窗id,这个Provider在create函数里有提供
const contextModalId = useContext(NiceModalIdContext);
let modalId: string | null = null;

modalId = contextModalId;

if (!modalId) throw new Error('No modal id found in NiceModal.useModal.');

const mid = modalId as string;

// 从modals中通过id获取到当前弹窗的信息,因为这部分函数在<NiceModalPlaceholder>渲染后才会触发,此时已经注册过了,所以能获取到
const modalInfo = modals[mid];

// 这里返回了前文提到的show函数,但前文的show函数也只解读了一半,剩下一半等会再说
const showCallback = useCallback((args?: Record<string, unknown>) => show(mid, args), [mid]);

return useMemo(
() => ({
args: modalInfo?.args,
show: showCallback,
}),
[
// ...
],
);
}

再来看看create函数,同样精简一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const ALREADY_MOUNTED = {};

export const create = <P extends {}>(
Comp: React.ComponentType<P>,
): React.FC<P & NiceModalHocProps> => {
return ({ defaultVisible, keepMounted, id, ...props }) => {
// 省略...

useEffect(() => {
// 省略...

ALREADY_MOUNTED[id] = true;

return () => {
delete ALREADY_MOUNTED[id];
};
}, [id, show, defaultVisible]);

// 省略...

const delayVisible = modals[id]?.delayVisible;

useEffect(() => {
if (delayVisible) {
show(args);
}
}, [delayVisible, args, show]);

// 省略...
};
};

精简后就两个useEffect,不过在看这部分逻辑前,再回顾下前文提到的reducer函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export const reducer = (
state: NiceModalStore = initialState,
action: NiceModalAction,
): NiceModalStore => {
switch (action.type) {
case 'nice-modal/show': {
const { modalId, args } = action.payload;
return {
...state,
[modalId]: {
...state[modalId], // 如果已有就修改
id: modalId,
args,
visible: !!ALREADY_MOUNTED[modalId],
delayVisible: !ALREADY_MOUNTED[modalId],
},
};
}
// 其他的暂时不看了...
}
};

重点关注visible字段和delayVisible字段,可以看到逻辑上他们是相反的,取决于ALREADY_MOUNTED里维护的对应弹窗的值(状态)

那么再看看create函数,首先第一个useEffect里将ALREADY_MOUNTED里弹窗的状态改为true,不过改了visible字段也不会立刻变为true,delayVisible字段也不会立刻变成false

接下来获取delayVisible字段的值,此时是true,那么就会触发show函数

1
2
3
4
5
6
7
const { args, show } = useModal(id);

// ...

if (delayVisible) {
show(args);
}

上面看过useModal的代码,暴露出来的show函数实际上就是最外层的show函数包了一层
那么就会再触发dispatch,就会更新modals里的数据,visibledelayVisible字段也随之更新

而且modals数据的变动会触发<NiceModalPlaceholder/>的更新,并将最新的props传入弹窗组件,因为visible变更为true了,所以弹窗出现

弹窗的Promise

在官方示例里可以看到,show函数实际返回的是个Promise

1
2
3
4
5
6
7
8
9
10
NiceModal.show(AddUserModal)
.then(() => {
// When call modal.resolve(payload) in the modal component
// it will resolve the promise returned by `show` method.
// fetchUsers will call the rest API and update the list
fetchUsers()
})
.catch(err=> {
// if modal.reject(new Error('something went wrong')), it will reject the promise
});

那么现在就需要看看上文中未分析的show函数另一半代码了
当调用show函数时,会创建一个Promise,并将其记录下来(在调用hide函数时会删掉)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const modalCallbacks: NiceModalCallbacks = {}

export function show(
modal: React.FC<any> | string,
args?: NiceModalArgs<React.FC<any>> | Record<string, unknown>,
) {
// 省略...

if (!modalCallbacks[modalId]) {
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;
}

可以在useModal中获取到上面的resolvereject方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export function useModal(modal?: any, args?: any): any {
// ...

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(
() => ({
// ...
resolve: resolveCallback,
reject: rejectCallback,
resolveHide,
}),
[
// ...
],
);
}

组件库的帮助方法

官方也包装好了一些帮助方法供组件库使用
这是官方示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import NiceModal, {
muiDialog,
muiDialogV5,
antdModal,
antdModalV5,
antdDrawer,
antdDrawerV5,
bootstrapDialog
} from '@ebay/nice-modal-react';

//...
const modal = useModal();
// For MUI
<Dialog {...muiDialog(modal)}>

// For MUI V5
<Dialog {...muiDialogV5(modal)}>

// For ant.design
<Modal {...antdModal(modal)}>

// For ant.design v4.23.0 or later
<Modal {...antdModalV5(modal)}>

// For antd drawer
<Drawer {...antdDrawer(modal)}>

// For antd drawer v4.23.0 or later
<Drawer {...antdDrawerV5(modal)}>

// For bootstrap dialog
<Dialog {...bootstrapDialog(modal)}>

实际上就是兼容了一些组件库之间有差异的props命名,并且在弹窗关闭的时候调用resolve

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const antdModal = (
modal: NiceModalHandler,
): { visible: boolean; onCancel: () => void; onOk: () => void; afterClose: () => void } => {
return {
visible: modal.visible,
onOk: () => modal.hide(),
onCancel: () => modal.hide(),
afterClose: () => {
// Need to resolve before remove
modal.resolveHide();
if (!modal.keepMounted) modal.remove();
},
};
};

杂项

  • defaultVisible这个参数仅在以<MyNiceModal defaultVisible />这种方式声明时生效,在register函数传入是不生效的。这个写法也不是官方推荐的写法,略过。