更新 24.3:已封装成组件库,欢迎大家来使用。
Modal 模态对话框。
一个非常常见的组件。不管是 C 端还是 B 端都能看到它的身影。但是你真的会用它吗?
什么是声明式 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>
)
}