为什么我更推荐命令式 Modal
Modal 模态对话框。
在中台业务中非常常见。
什么是声明式 Modal
组件库中一般都会内置这类组件,最为参见的声明式 Modal 定义。
例如 Antd 5 中的声明式 Modal 是这样定义的。
1const App: React.FC = () => {
2 const [isModalOpen, setIsModalOpen] = useState(false)
3
4 const showModal = () => {
5 setIsModalOpen(true)
6 }
7
8 const handleOk = () => {
9 setIsModalOpen(false)
10 }
11
12 const handleCancel = () => {
13 setIsModalOpen(false)
14 }
15
16 return (
17 <>
18 <Button type="primary" onClick={showModal}>
19 Open Modal
20 </Button>
21 <Modal
22 title="Basic Modal"
23 open={isModalOpen}
24 onOk={handleOk}
25 onCancel={handleCancel}
26 >
27 <p>Some contents...</p>
28 <p>Some contents...</p>
29 <p>Some contents...</p>
30 </Modal>
31 </>
32 )
33}
34
上面是一个受控的声明式 Modal 定义,写起来非常臃肿。你需要手动控制 Modal 的 Open 状态。并且你需要首先定义一个状态,然后在编写 UI,将状态和 UI 绑定。
这样的写法,我们需要在同一个组件定义一个状态,一个触发器(例如 Button)-> 控制状态 -> 流转到 Modal 显示。不仅写起来复杂,后期维护起来也很困难。
业务越积越多,后面你的页面上可能是这样的。
1<>
2 <Button type="primary" onClick={showModal}>
3 Open Modal 1
4 </Button>
5
6 <Button type="primary" onClick={showModal}>
7 Open Modal 2
8 </Button>
9
10 {/* More buttons */}
11 <Modal
12 title="Basic Modal"
13 open={isModalOpen}
14 onOk={handleOk}
15 onCancel={handleCancel}
16 >
17 <p>Some contents...</p>
18 </Modal>
19
20 <Modal
21 title="Basic Modal 2"
22 open={isModalOpen}
23 onOk={handleOk}
24 onCancel={handleCancel}
25 >
26 <p>Some contents...</p>
27 </Modal>
28 <Modal
29 title="Basic Modal 3"
30 open={isModalOpen}
31 onOk={handleOk}
32 onCancel={handleCancel}
33 >
34 <p>Some contents...</p>
35 </Modal>
36</>
37
一个组件中填满了无数个 Modal 和 Button。
这个时候你会想去抽离 Modal 到外部。像这样:
1const App: React.FC = () => {
2 const [isModalOpen, setIsModalOpen] = useState(false)
3
4 const showModal = () => {
5 setIsModalOpen(true)
6 }
7
8 const handleOk = () => {
9 setIsModalOpen(false)
10 }
11
12 const handleCancel = () => {
13 setIsModalOpen(false)
14 }
15
16 return (
17 <>
18 <Button type="primary" onClick={showModal}>
19 Open Modal
20 </Button>
21 <BaseModal1 {...{ isModalOpen, handleOk, handleCancel }} />
22 </>
23 )
24}
25const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
26 return (
27 <Modal
28 title="Basic Modal"
29 open={isModalOpen}
30 onOk={handleOk}
31 onCancel={handleCancel}
32 >
33 <p>Some contents...</p>
34 </Modal>
35 )
36}
37
然后你会发现控制 Modal 的状态还是在父组件顶层。导致父组件状态堆积越来越多。
1const App: React.FC = () => {
2 const [isModalOpen, setIsModalOpen] = useState(false)
3 const [isModalOpen2, setIsModalOpen2] = useState(false)
4 const [isModalOpen3, setIsModalOpen3] = useState(false)
5 // ....
6}
7
然后你思来想去,直接把 Modal 和 Button 抽离到一起。
1const App: React.FC = () => {
2 return <BaseModal1 />
3}
4const BaseModal1 = () => {
5 const [isModalOpen, setIsModalOpen] = useState(false)
6
7 const showModal = () => {
8 setIsModalOpen(true)
9 }
10
11 const handleOk = () => {
12 setIsModalOpen(false)
13 }
14
15 const handleCancel = () => {
16 setIsModalOpen(false)
17 }
18 return (
19 <>
20 <Button type="primary" onClick={showModal}>
21 Open Modal
22 </Button>
23 <Modal
24 title="Basic Modal"
25 open={isModalOpen}
26 onOk={handleOk}
27 onCancel={handleCancel}
28 >
29 <p>Some contents...</p>
30 </Modal>
31 </>
32 )
33}
34
好了,这样 Button 和 Modal 直接耦合了,后续你想单独复用 Modal 几乎不可能了。
想来想去,再把 Modal 拆了。像这样:
1const App: React.FC = () => {
2 return <BaseModal1WithButton />
3}
4const BaseModal1WithButton = () => {
5 const [isModalOpen, setIsModalOpen] = useState(false)
6
7 const showModal = () => {
8 setIsModalOpen(true)
9 }
10
11 const handleOk = () => {
12 setIsModalOpen(false)
13 }
14
15 const handleCancel = () => {
16 setIsModalOpen(false)
17 }
18 return (
19 <>
20 <Button type="primary" onClick={showModal}>
21 Open Modal
22 </Button>
23 <BaseModal1 open={isModalOpen} onOk={handleOk} onCancel={handleCancel} />
24 </>
25 )
26}
27
28const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
29 return (
30 <Modal
31 title="Basic Modal"
32 open={isModalOpen}
33 onOk={handleOk}
34 onCancel={handleCancel}
35 >
36 <p>Some contents...</p>
37 </Modal>
38 )
39}
40
我去,为了解耦一个 Modal 居然要写这么多代码,而且还是不可复用的,乱七八糟的状态。
想象一下这才一个 Modal,就要写这么多。
后来,你又会遇到了这样的问题,因为控制 Modal 状态下沉了,导致你的 Modal 无法在父组件中直接控制。
然后你会直接在外部 Store 或者 Context 中下放这个状态。
1import { atom } from 'jotai'
2const BasicModal1OpenedAtomContext = createContext(atom(false))
3const App: React.FC = () => {
4 const ctxValue = useMemo(() => atom(false), [])
5
6 return (
7 <BasicModal1OpenedAtomContext.Provider value={ctxValue}>
8 <button
9 onClick={() => {
10 jotaiStore.set(ctxValue, true)
11 }}
12 >
13 Open Modal 1
14 </button>
15 <BaseModal1WithButton />
16 </BasicModal1OpenedAtomContext.Provider>
17 )
18}
19
20const BaseModal1 = ({ handleOk, handleCancel }) => {
21 const [isModalOpen, setIsModalOpen] = useAtom(
22 useContext(BasicModal1OpenedAtomContext),
23 )
24 return (
25 <Modal
26 title="Basic Modal"
27 open={isModalOpen}
28 onOk={handleOk}
29 onCancel={handleCancel}
30 >
31 <p>Some contents...</p>
32 </Modal>
33 )
34}
35
最后 ctx 或者 Store 里面的状态越来越多,你会发现你的代码越来越难以维护。最后你都不知道这个 Modal 状态到底需不需要。
何况,Modal 就算没有显示,但是 Modal 还是存在于 React tree 上的,祖先组件的状态更新,也会导致 Modal 重新渲染产生性能开销。
快试试命令式 Modal
某些组件库中也会提供命令式 Modal,在 Antd 5 中是这样的。
1function Comp() {
2 const [modal, contextHolder] = Modal.useModal()
3
4 return (
5 <>
6 <Button
7 onClick={async () => {
8 const confirmed = await modal.confirm(config)
9 console.log('Confirmed: ', confirmed)
10 }}
11 >
12 Confirm
13 </Button>
14 {contextHolder}
15 </>
16 )
17}
18
上面的写法是不是简单了很多,不在需要外部状态控制显示隐藏。
但是看上去这个命令式 Modal 定义过于简单,一般只适用于对话框提示。并不会去承载复杂的业务逻辑。
实现一个命令式 Modal
好了,按照这样的思路,我们可以尝试一下自己实现一个命令式 Modal。我们实现的 Modal 需要做到最小化迁移原先的声明式 Modal,同时又能够承载复杂的业务逻辑。
:::note
我们实现的 Modal 都是使用命令式调出,使用声明式来编写 UI。
:::
总体思路,我们需要在应用顶层使用一个 Context 来存储所有 Modal 的状态。当 Modal 使用 present
时,创建一个 Modal 实例记录到 Context 中。当 Modal 被关闭时,销毁 Modal 实例。所以在顶层 ModalStack
中的状态应该只包含现在渲染的 Modal 实例。最大化节省内存资源。
接下来我使用 Antd Modal + Jotai 的进行实现。其他类似组件实现方式基本一致。
首先,我们实现 ModalStack
。
1import { ModalProps as AntdModalProps, Modal } from 'antd'
2
3type ModalProps = {
4 id?: string
5
6 content: ReactNode | ((props: ModalContentProps) => ReactNode)
7} & Omit<AntdModalProps, 'open'>
8
9const modalStackAtom = atom([] as (Omit<ModalProps, 'id'> & { id: string })[])
10
11const ModalStack = () => {
12 const stack = useAtomValue(modalStackAtom)
13
14 return (
15 <>
16 {stack.map((props, index) => {
17 return <ModalImpl key={props.id} {...props} index={index} />
18 })}
19 </>
20 )
21}
22
定义 useModalStack
用于唤出 Modal。
1const modalIdToPropsMap = {} as Record<string, ModalProps>
2
3export const presetModal = (
4 props: ModalProps,
5 modalId = ((Math.random() * 10) | 0).toString(),
6) => {
7 jotaiStore.set(modalStackAtom, (p) => {
8 const modalProps = {
9 ...props,
10 id: props.id ?? modalId,
11 } satisfies ModalProps
12 modalIdToPropsMap[modalProps.id!] = modalProps
13 return p.concat(modalProps)
14 })
15
16 return () => {
17 jotaiStore.set(modalStackAtom, (p) => {
18 return p.filter((item) => item.id !== modalId)
19 })
20 }
21}
22
23export const useModalStack = () => {
24 const id = useId()
25 const currentCount = useRef(0)
26 return {
27 present(props: ModalProps) {
28 const modalId = `${id}-${currentCount.current++}`
29 return presetModal(props, modalId)
30 },
31 }
32}
33
上面的代码,我们定义了 modalStackAtom
用于存储 Modal 实例。 presetModal
用于唤出 Modal。 useModalStack
的 present
用于唤出一个新的 Modal。
由于我们使用了 Jotai 外部状态去管理 Modal 实例,所以 presetModal
被提取到了外部,日后我们可以直接脱离 React 使用。
注意这个类型定义,我们基本直接继承了原有的 ModalProps
,但是过滤了 open
属性。因为我们不需要外部控制 Modal 的显示隐藏,而是直接在 ModalStack
中控制 Modal 的显隐。
而 content
属性,后续方便我们去扩展传入的 props。比如这里,我们可以传入一个 ModalActions 作为 props。那么以后定义 Content 时候可以直接接受一个 props,通过 dismiss 方法关闭当前 Modal。
1type ModalContentProps = {
2 dismiss: () => void
3}
4
5type ModalProps = {
6 id?: string
7
8 content: ReactNode | ((props: ModalContentProps) => ReactNode)
9} & Omit<AntdModalProps, 'open' | 'content'>
10
<ModalImpl />
的实现是非常简单的,在此之前,我们先定义一下 ModalActionContext
,后续可以在 Modal 中直接调用使用。
1const actions = {
2 dismiss(id: string) {
3 jotaiStore.set(modalStackAtom, (p) => {
4 return p.filter((item) => item.id !== id)
5 })
6 },
7 dismissTop() {
8 jotaiStore.set(modalStackAtom, (p) => {
9 return p.slice(0, -1)
10 })
11 },
12 dismissAll() {
13 jotaiStore.set(modalStackAtom, [])
14 },
15}
16
改进 useModalStack
1export const useModalStack = () => {
2 const id = useId()
3 const currentCount = useRef(0)
4 return {
5 present: useEventCallback((props: ModalProps) => {
6 const modalId = `${id}-${currentCount.current++}`
7 return presetModal(props, modalId)
8 }),
9+ ...actions
10 }
11}
12
现在可以通过 useModalStack().dismiss
关闭某个 Modal 了,也可以通过 useModalStack().dismissTop
关闭最上层的 Modal 等等。
现在编写 <ModalImpl />
:
1const ModalActionContext = createContext<{
2 dismiss: () => void
3}>(null!)
4
5export const useCurrentModalAction = () => useContext(ModalActionContext)
6
7const ModalImpl: FC<
8 Omit<ModalProps, 'id'> & {
9 id: string
10 index: number
11 }
12> = memo((props) => {
13 const { content } = props
14 const [open, setOpen] = useState(true)
15 const setStack = useSetAtom(modalStackAtom)
16
17 const removeFromStack = useEventCallback(() => {
18 setStack((p) => {
19 return p.filter((item) => item.id !== props.id)
20 })
21 })
22
23 useEffect(() => {
24 let isCancelled = false
25 let timerId: any
26 if (!open) {
27 timerId = setTimeout(() => {
28 if (isCancelled) return
29 removeFromStack()
30 }, 1000) // 这里控制一个时间差,等待 Modal 关闭后的动画完成,销毁 Modal 实例
31 }
32 return () => {
33 isCancelled = true
34 clearTimeout(timerId)
35 }
36 }, [open, removeFromStack])
37 const onCancel = useEventCallback(() => {
38 setOpen(false)
39 props.onCancel?.()
40 })
41
42 return (
43 <ModalActionContext.Provider // 这里在当前 Modal 上下文提供一些 Modal Actions
44 value={useMemo(() => ({ dismiss: onCancel }), [onCancel])}
45 >
46 <Modal {...props} open={open} destroyOnClose onCancel={onCancel}>
47 {typeof content === 'function'
48 ? createElement(content, { dismiss: onCancel }) // 这里可以通过 props 传递参数到 content 中
49 : content}
50 </Modal>
51 </ModalActionContext.Provider>
52 )
53})
54ModalImpl.displayName = 'ModalImpl'
55
OK,这样就整体实现完了。
现在我们来到 React App 顶层组件,挂载 <ModalStack />
。
1const App = document.getElementById('root')
2const Root: FC = () => {
3 return (
4 <div>
5 <ModalStack />
6 </div>
7 )
8}
9
然后像这样使用:
1 <div>
2 <ModalStack />
3+ <Page />
4 </div>
5
1const Page = () => {
2 const { present } = useModalStack()
3 return (
4 <>
5 <div>
6 <button
7 onClick={() => {
8 present({
9 title: 'Title',
10 content: <ModalContent />,
11 })
12 }}
13 >
14 Modal Stack
15 </button>
16 </div>
17 </>
18 )
19}
20
21const ModalContent = () => {
22 const { dismiss } = useCurrentModalAction() // 控制当前 Modal 的 actions
23
24 return (
25 <div>
26 This Modal content.
27 <br />
28 <button onClick={dismiss}>Dismiss</button>
29 </div>
30 )
31}
32
当然你也可以在 Modal 内部继续使用 useModalStack
唤出新的 Modal。
1const ModalContent = () => {
2 const { dismiss } = useCurrentModalAction()
3 const { present, dismissAll } = useModalStack()
4
5 return (
6 <div>
7 This Modal content.
8 <ButtonGroup>
9 <Button
10 onClick={() => {
11 present({
12 title: 'Title',
13 content: <ModalContent />,
14 })
15 }}
16 >
17 Present New
18 </Button>
19 <Button onClick={dismiss}>Dismiss This</Button>
20 <Button onClick={dismissAll}>Dismiss All</Button>
21 </ButtonGroup>
22 </div>
23 )
24}
25
甚至,你可以在 React 外部使用。
1const eventHandler = (type: Events) => {
2 switch (type) {
3 case 'Notify':
4 presetModal({
5 title: 'Title',
6 content: () => createElement('div', null, 'Some notify here'),
7 })
8 }
9}
10
从声明式迁移到命令式
由于我们在创建 Modal 时候传递了所有的原有参数,所以迁移过程非常丝滑,只需要把原本在 Modal 上传递的 props 直接移到命令式上就行。
1const App: React.FC = () => {
2- const [isModalOpen, setIsModalOpen] = useState(false)
3
4 const showModal = () => {
5- setIsModalOpen(true)
6
7 present({
8 title: 'Basic Modal',
9 content: <ModalContent />,
10 // pass other modal props
11 })
12 }
13
14- const handleOk = () => {
15- setIsModalOpen(false)
16- }
17-
18- const handleCancel = () => {
19- setIsModalOpen(false)
20- }
21
22 const { present } = useModalStack()
23
24 return (
25- <>
26 <Button type="primary" onClick={showModal}>
27 Open Modal
28 </Button>
29- <Modal
30- title="Basic Modal"
31- open={isModalOpen}
32- onOk={handleOk}
33- onCancel={handleCancel}
34- >
35- <ModalContent />,
36- </Modal>
37- </>
38 )
39}
40
然后你还可以封装一个 hook,在随处唤出这个 Modal。
1// modal1.tsx
2import React, { useCallback } from 'react'
3
4import { useModalStack } from './modal-stack'
5
6export const useBiz1Modal = () => {
7 const { present } = useModalStack()
8
9 return {
10 presentBiz1Modal: useCallback(() => {
11 present({
12 title: 'Biz1',
13 content: () => <ModalContent />,
14 // other pass modal props
15 })
16 }, [present]),
17 }
18}
19
20const ModalContent = () => {
21 return <div>content</div>
22}
23
完整案例
上面是基于 Antd 实现的一版,如果你的组件库没有提供命令式 Modal API,完全可以根据这个思路自己实现。
当然在某些情况下,我们可能需要不借助组件库实现一个 Modal。
而自己实现一个 Modal 你更需要考虑 Modal 堆叠时候的层级问题和出场动画的问题。
在很久以前,我曾在 kami 中实现了最初的一版。没有借助任何组件库和动画库。
在此后的一段时间里,我为 xLog 传递过这个思想,进行了一些重构。
而目前在 Shiro 中,我使用 Radix + framer motion 实现了一个较为可用的 ModalStack
,可以进行参考。
总结
优点
- 状态解耦:命令式 Modal 允许我们将 Modal 的状态管理从组件内部解耦出来,这样不仅简化了组件本身的逻辑,也使得状态管理更为灵活和清晰。
- 删起来快:由于 Modal 的逻辑不再与特定组件紧密绑定,当需要移除或更改 Modal 时,我们可以更快速地进行修改,无需深入繁杂的组件树结构。
- 写起来方便:命令式的写法相对简洁直观,尤其在需要快速实现功能时,能够大幅减少编码工作量。这对于快速迭代的项目来说是一个显著的优势。
- 复用方便:命令式 Modal 由于其解耦的特性,使得在不同的组件或场景中复用变得更加容易,提高了开发效率和代码的可维护性。
缺点
- 数据响应式更新的限制:命令式 Modal 的一个主要缺点是无法直接通过 props 实现数据的响应式更新。这意味着当 Modal 需要响应外部数据变化时,可能需要依赖外部状态管理库(如 Redux、MobX 等)来实现。这增加了一定的复杂性,并可能导致状态管理分散于不同的系统或框架中。
原文发布于: