Modal 模态对话框。
在中台业务中非常常见。
什么是声明式 Modal
组件库中一般都会内置这类组件,最为参见的声明式 Modal 定义。
例如 Antd 5 中的声明式 Modal 是这样定义的。
const App: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
const showModal = () => {
setIsModalOpen(true)
}
const handleOk = () => {
setIsModalOpen(false)
}
const handleCancel = () => {
setIsModalOpen(false)
}
return (
<>
<Button type="primary" onClick={showModal}>
Open Modal
</Button>
<Modal
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
<p>Some contents...</p>
<p>Some contents...</p>
</Modal>
</>
)
}
上面是一个受控的声明式 Modal 定义,写起来非常臃肿。你需要手动控制 Modal 的 Open 状态。并且你需要首先定义一个状态,然后在编写 UI,将状态和 UI 绑定。
这样的写法,我们需要在同一个组件定义一个状态,一个触发器(例如 Button)-> 控制状态 -> 流转到 Modal 显示。不仅写起来复杂,后期维护起来也很困难。
业务越积越多,后面你的页面上可能是这样的。
<>
<Button type="primary" onClick={showModal}>
Open Modal 1
</Button>
<Button type="primary" onClick={showModal}>
Open Modal 2
</Button>
{/* More buttons */}
<Modal
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
</Modal>
<Modal
title="Basic Modal 2"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
</Modal>
<Modal
title="Basic Modal 3"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
</Modal>
</>
一个组件中填满了无数个 Modal 和 Button。
这个时候你会想去抽离 Modal 到外部。像这样:
const App: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
const showModal = () => {
setIsModalOpen(true)
}
const handleOk = () => {
setIsModalOpen(false)
}
const handleCancel = () => {
setIsModalOpen(false)
}
return (
<>
<Button type="primary" onClick={showModal}>
Open Modal
</Button>
<BaseModal1 {...{ isModalOpen, handleOk, handleCancel }} />
</>
)
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
return (
<Modal
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
</Modal>
)
}
然后你会发现控制 Modal 的状态还是在父组件顶层。导致父组件状态堆积越来越多。
const App: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
const [isModalOpen2, setIsModalOpen2] = useState(false)
const [isModalOpen3, setIsModalOpen3] = useState(false)
// ....
}
然后你思来想去,直接把 Modal 和 Button 抽离到一起。
const App: React.FC = () => {
return <BaseModal1 />
}
const BaseModal1 = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
const showModal = () => {
setIsModalOpen(true)
}
const handleOk = () => {
setIsModalOpen(false)
}
const handleCancel = () => {
setIsModalOpen(false)
}
return (
<>
<Button type="primary" onClick={showModal}>
Open Modal
</Button>
<Modal
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
</Modal>
</>
)
}
好了,这样 Button 和 Modal 直接耦合了,后续你想单独复用 Modal 几乎不可能了。
想来想去,再把 Modal 拆了。像这样:
const App: React.FC = () => {
return <BaseModal1WithButton />
}
const BaseModal1WithButton = () => {
const [isModalOpen, setIsModalOpen] = useState(false)
const showModal = () => {
setIsModalOpen(true)
}
const handleOk = () => {
setIsModalOpen(false)
}
const handleCancel = () => {
setIsModalOpen(false)
}
return (
<>
<Button type="primary" onClick={showModal}>
Open Modal
</Button>
<BaseModal1 open={isModalOpen} onOk={handleOk} onCancel={handleCancel} />
</>
)
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
return (
<Modal
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
</Modal>
)
}
我去,为了解耦一个 Modal 居然要写这么多代码,而且还是不可复用的,乱七八糟的状态。
想象一下这才一个 Modal,就要写这么多。
后来,你又会遇到了这样的问题,因为控制 Modal 状态下沉了,导致你的 Modal 无法在父组件中直接控制。
然后你会直接在外部 Store 或者 Context 中下放这个状态。
import { atom } from 'jotai'
const BasicModal1OpenedAtomContext = createContext(atom(false))
const App: React.FC = () => {
const ctxValue = useMemo(() => atom(false), [])
return (
<BasicModal1OpenedAtomContext.Provider value={ctxValue}>
<button
onClick={() => {
jotaiStore.set(ctxValue, true)
}}
>
Open Modal 1
</button>
<BaseModal1WithButton />
</BasicModal1OpenedAtomContext.Provider>
)
}
const BaseModal1 = ({ handleOk, handleCancel }) => {
const [isModalOpen, setIsModalOpen] = useAtom(
useContext(BasicModal1OpenedAtomContext),
)
return (
<Modal
title="Basic Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>Some contents...</p>
</Modal>
)
}
最后 ctx 或者 Store 里面的状态越来越多,你会发现你的代码越来越难以维护。最后你都不知道这个 Modal 状态到底需不需要。
何况,Modal 就算没有显示,但是 Modal 还是存在于 React tree 上的,祖先组件的状态更新,也会导致 Modal 重新渲染产生性能开销。
快试试命令式 Modal
某些组件库中也会提供命令式 Modal,在 Antd 5 中是这样的。
function Comp() {
const [modal, contextHolder] = Modal.useModal()
return (
<>
<Button
onClick={async () => {
const confirmed = await modal.confirm(config)
console.log('Confirmed: ', confirmed)
}}
>
Confirm
</Button>
{contextHolder}
</>
)
}
上面的写法是不是简单了很多,不在需要外部状态控制显示隐藏。
但是看上去这个命令式 Modal 定义过于简单,一般只适用于对话框提示。并不会去承载复杂的业务逻辑。
实现一个命令式 Modal
好了,按照这样的思路,我们可以尝试一下自己实现一个命令式 Modal。我们实现的 Modal 需要做到最小化迁移原先的声明式 Modal,同时又能够承载复杂的业务逻辑。
总体思路,我们需要在应用顶层使用一个 Context 来存储所有 Modal 的状态。当 Modal 使用 present
时,创建一个 Modal 实例记录到 Context 中。当 Modal 被关闭时,销毁 Modal 实例。所以在顶层 ModalStack
中的状态应该只包含现在渲染的 Modal 实例。最大化节省内存资源。
接下来我使用 Antd Modal + Jotai 的进行实现。其他类似组件实现方式基本一致。
首先,我们实现 ModalStack
。
import { ModalProps as AntdModalProps, Modal } from 'antd'
type ModalProps = {
id?: string
content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open'>
const modalStackAtom = atom([] as (Omit<ModalProps, 'id'> & { id: string })[])
const ModalStack = () => {
const stack = useAtomValue(modalStackAtom)
return (
<>
{stack.map((props, index) => {
return <ModalImpl key={props.id} {...props} index={index} />
})}
</>
)
}
定义 useModalStack
用于唤出 Modal。
const modalIdToPropsMap = {} as Record<string, ModalProps>
export const presetModal = (
props: ModalProps,
modalId = ((Math.random() * 10) | 0).toString(),
) => {
jotaiStore.set(modalStackAtom, (p) => {
const modalProps = {
...props,
id: props.id ?? modalId,
} satisfies ModalProps
modalIdToPropsMap[modalProps.id!] = modalProps
return p.concat(modalProps)
})
return () => {
jotaiStore.set(modalStackAtom, (p) => {
return p.filter((item) => item.id !== modalId)
})
}
}
export const useModalStack = () => {
const id = useId()
const currentCount = useRef(0)
return {
present(props: ModalProps) {
const modalId = `${id}-${currentCount.current++}`
return presetModal(props, modalId)
},
}
}
上面的代码,我们定义了 modalStackAtom
用于存储 Modal 实例。presetModal
用于唤出 Modal。useModalStack
的 present
用于唤出一个新的 Modal。
由于我们使用了 Jotai 外部状态去管理 Modal 实例,所以 presetModal
被提取到了外部,日后我们可以直接脱离 React 使用。
注意这个类型定义,我们基本直接继承了原有的 ModalProps
,但是过滤了 open
属性。因为我们不需要外部控制 Modal 的显示隐藏,而是直接在 ModalStack
中控制 Modal 的显隐。
而 content
属性,后续方便我们去扩展传入的 props。比如这里,我们可以传入一个 ModalActions 作为 props。那么以后定义 Content 时候可以直接接受一个 props,通过 dismiss 方法关闭当前 Modal。
type ModalContentProps = {
dismiss: () => void
}
type ModalProps = {
id?: string
content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open' | 'content'>
<ModalImpl />
的实现是非常简单的,在此之前,我们先定义一下 ModalActionContext
,后续可以在 Modal 中直接调用使用。
const actions = {
dismiss(id: string) {
jotaiStore.set(modalStackAtom, (p) => {
return p.filter((item) => item.id !== id)
})
},
dismissTop() {
jotaiStore.set(modalStackAtom, (p) => {
return p.slice(0, -1)
})
},
dismissAll() {
jotaiStore.set(modalStackAtom, [])
},
}
改进 useModalStack
export const useModalStack = () => {
const id = useId()
const currentCount = useRef(0)
return {
present: useCallback((props: ModalProps) => {
const modalId = `${id}-${currentCount.current++}`
return presetModal(props, modalId)
}, []),
+ ...actions
}
}
现在可以通过 useModalStack().dismiss
关闭某个 Modal 了,也可以通过 useModalStack().dismissTop
关闭最上层的 Modal 等等。
现在编写 <ModalImpl />
:
const ModalActionContext = createContext<{
dismiss: () => void
}>(null!)
export const useCurrentModalAction = () => useContext(ModalActionContext)
const ModalImpl: FC<
Omit<ModalProps, 'id'> & {
id: string
index: number
}
> = memo((props) => {
const { content } = props
const [open, setOpen] = useState(true)
const setStack = useSetAtom(modalStackAtom)
const removeFromStack = useEventCallback(() => {
setStack((p) => {
return p.filter((item) => item.id !== props.id)
})
})
useEffect(() => {
let isCancelled = false
let timerId: any
if (!open) {
timerId = setTimeout(() => {
if (isCancelled) return
removeFromStack()
}, 1000) // 这里控制一个时间差,等待 Modal 关闭后的动画完成,销毁 Modal 实例
}
return () => {
isCancelled = true
clearTimeout(timerId)
}
}, [open, removeFromStack])
const onCancel = useEventCallback(() => {
setOpen(false)
props.onCancel?.()
})
return (
<ModalActionContext.Provider // 这里在当前 Modal 上下文提供一些 Modal Actions
value={useMemo(() => ({ dismiss: onCancel }), [onCancel])}
>
<Modal {...props} open={open} destroyOnClose onCancel={onCancel}>
{typeof content === 'function'
? createElement(content, { dismiss: onCancel }) // 这里可以通过 props 传递参数到 content 中
: content}
</Modal>
</ModalActionContext.Provider>
)
})
ModalImpl.displayName = 'ModalImpl'
OK,这样就整体实现完了。
现在我们来到 React App 顶层组件,挂载 <ModalStack />
。
const App = document.getElementById('root')
const Root: FC = () => {
return (
<div>
<ModalStack />
</div>
)
}
然后像这样使用:
<div>
<ModalStack />
+ <Page />
</div>
const Page = () => {
const { present } = useModalStack()
return (
<>
<div>
<button
onClick={() => {
present({
title: 'Title',
content: <ModalContent />,
})
}}
>
Modal Stack
</button>
</div>
</>
)
}
const ModalContent = () => {
const { dismiss } = useCurrentModalAction() // 控制当前 Modal 的 actions
return (
<div>
This Modal content.
<br />
<button onClick={dismiss}>Dismiss</button>
</div>
)
}
当然你也可以在 Modal 内部继续使用 useModalStack
唤出新的 Modal。
const ModalContent = () => {
const { dismiss } = useCurrentModalAction()
const { present, dismissAll } = useModalStack()
return (
<div>
This Modal content.
<ButtonGroup>
<Button
onClick={() => {
present({
title: 'Title',
content: <ModalContent />,
})
}}
>
Present New
</Button>
<Button onClick={dismiss}>Dismiss This</Button>
<Button onClick={dismissAll}>Dismiss All</Button>
</ButtonGroup>
</div>
)
}