更新 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}>
打開 Modal
</Button>
<Modal
title="基本 Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</p>
<p>一些內容...</p>
<p>一些內容...</p>
</Modal>
</>
)
}
上面是一個受控的聲明式 Modal 定義,寫起來非常臃腫。你需要手動控制 Modal 的 Open 狀態。並且你需要首先定義一個狀態,然後在編寫 UI,將狀態和 UI 綁定。
這樣的寫法,我們需要在同一個組件定義一個狀態,一個觸發器(例如 Button)-> 控制狀態 -> 流轉到 Modal 顯示。不僅寫起來複雜,後期維護起來也很困難。
業務越積越多,後面你的頁面上可能是這樣的。
<>
<Button type="primary" onClick={showModal}>
打開 Modal 1
</Button>
<Button type="primary" onClick={showModal}>
打開 Modal 2
</Button>
{/* 更多按鈕 */}
<Modal
title="基本 Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</p>
</Modal>
<Modal
title="基本 Modal 2"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</p>
</Modal>
<Modal
title="基本 Modal 3"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</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}>
打開 Modal
</Button>
<BaseModal1 {...{ isModalOpen, handleOk, handleCancel }} />
</>
)
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
return (
<Modal
title="基本 Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</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}>
打開 Modal
</Button>
<Modal
title="基本 Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</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}>
打開 Modal
</Button>
<BaseModal1 open={isModalOpen} onOk={handleOk} onCancel={handleCancel} />
</>
)
}
const BaseModal1 = ({ isModalOpen, handleOk, handleCancel }) => {
return (
<Modal
title="基本 Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</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)
}}
>
打開 Modal 1
</button>
<BaseModal1WithButton />
</BasicModal1OpenedAtomContext.Provider>
)
}
const BaseModal1 = ({ handleOk, handleCancel }) => {
const [isModalOpen, setIsModalOpen] = useAtom(
useContext(BasicModal1OpenedAtomContext),
)
return (
<Modal
title="基本 Modal"
open={isModalOpen}
onOk={handleOk}
onCancel={handleCancel}
>
<p>一些內容...</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)
}}
>
確認
</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: '標題',
content: <ModalContent />,
})
}}
>
Modal 堆疊
</button>
</div>
</>
)
}
const ModalContent = () => {
const { dismiss } = useCurrentModalAction() // 控制當前 Modal 的 actions
return (
<div>
這個 Modal 內容。
<br />
<button onClick={dismiss}>關閉</button>
</div>
)
}
當然你也可以在 Modal 內部繼續使用 useModalStack
喚出新的 Modal。
const ModalContent = () => {
const { dismiss } = useCurrentModalAction()
const { present, dismissAll } = useModalStack()
return (
<div>
這個 Modal 內容。
<ButtonGroup>
<Button
onClick={() => {
present({
title: '標題',
content: <ModalContent />,
})
}}
>
顯示新 Modal
</Button>
<Button onClick={dismiss}>關閉這個</Button>
<Button onClick={dismissAll}>關閉所有</Button>
</ButtonGroup>
</div>
)
}