为什么要用命令行,modal和v-modal

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,同时又能够承载复杂的业务逻辑。

:::note

我们实现的 Modal 都是使用命令式调出,使用声明式来编写 UI。

:::

总体思路,我们需要在应用顶层使用一个 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: useEventCallback((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> )}

甚至,你可以在 React 外部使用。

const eventHandler = (type: Events) => { switch (type) { case 'Notify': presetModal({ title: 'Title', content: () => createElement('div', null, 'Some notify here'), }) }}

从声明式迁移到命令式

由于我们在创建 Modal 时候传递了所有的原有参数,所以迁移过程非常丝滑,只需要把原本在 Modal 上传递的 props 直接移到命令式上就行。

const App: React.FC = () => {- const [isModalOpen, setIsModalOpen] = useState(false) const showModal = () => {- setIsModalOpen(true) present({ title: 'Basic Modal', content: <ModalContent />, // pass other modal props }) }- const handleOk = () => {- setIsModalOpen(false)- }-- const handleCancel = () => {- setIsModalOpen(false)- } const { present } = useModalStack() return (- <> <Button type="primary" OnClick={showModal}> Open Modal </Button>- <Modal- title="Basic Modal"- open={isModalOpen}- OnOk={handleOk}- OnCancel={handleCancel}- >- <ModalContent />,- </Modal>- </> )}

然后你还可以封装一个 hook,在随处唤出这个 Modal。

// modal1.tsximport React, { useCallback } from 'react'import { useModalStack } from './modal-stack'export const useBiz1Modal = () => { const { present } = useModalStack() return { presentBiz1Modal: useCallback(() => { present({ title: 'Biz1', content: () => <ModalContent />, // other pass modal props }) }, [present]), }}const ModalCOntent= () => { return <div>content</div>}

完整案例

上面是基于 Antd 实现的一版,如果你的组件库没有提供命令式 Modal API,完全可以根据这个思路自己实现。

当然在某些情况下,我们可能需要不借助组件库实现一个 Modal。

而自己实现一个 Modal 你更需要考虑 Modal 堆叠时候的层级问题和出场动画的问题。

在很久以前,我曾在 kami 中实现了最初的一版。没有借助任何组件库和动画库。

github.com/mx-space/ka…

在此后的一段时间里,我为 xLog 传递过这个思想,进行了一些重构。

github.com/Crossbell-B…

而目前在 Shiro 中,我使用 Radix + framer motion 实现了一个较为可用的 ModalStack,可以进行参考。

github.com/Innei/Shiro…

总结

优点

  • 状态解耦:命令式 Modal 允许我们将 Modal 的状态管理从组件内部解耦出来,这样不仅简化了组件本身的逻辑,也使得状态管理更为灵活和清晰。
  • 删起来快:由于 Modal 的逻辑不再与特定组件紧密绑定,当需要移除或更改 Modal 时,我们可以更快速地进行修改,无需深入繁杂的组件树结构。
  • 写起来方便:命令式的写法相对简洁直观,尤其在需要快速实现功能时,能够大幅减少编码工作量。这对于快速迭代的项目来说是一个显著的优势。
  • 复用方便:命令式 Modal 由于其解耦的特性,使得在不同的组件或场景中复用变得更加容易,提高了开发效率和代码的可维护性。

缺点

  • 数据响应式更新的限制:命令式 Modal 的一个主要缺点是无法直接通过 props 实现数据的响应式更新。这意味着当 Modal 需要响应外部数据变化时,可能需要依赖外部状态管理库(如 Redux、MobX 等)来实现。这增加了一定的复杂性,并可能导致状态管理分散于不同的系统或框架中。

作者:innei79224
链接:***/post/7295943233740636198

声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。E-MAIL:dandanxi6@qq.com

(0)
上一篇 2024年 2月 1日 下午2:11
下一篇 2024年 2月 1日 下午2:16

相关推荐

  • 美人心计张太后,重温《美人心计》才知道张嫣为什么能够追到爱情,获得幸福

    一朝天子一朝臣,吕后死后,刘恒母子正式上位,窦漪房成了母仪天下的皇后娘娘。 窦漪房以前在汉宫的时候,伺候过前朝太后张嫣,她们关系很好,几乎没有上下之分,张嫣心里一直把窦漪房当作姐姐…

    2023年 1月 11日
  • 如果你有心事放不下,不妨读读《兰亭集序》

    之前,一则趣闻刷屏了网络: 意大利女航天员克里斯托弗雷蒂在国际空间站执行任务时,发布了一组太空摄影作品,并用中英意三种语言配上了一段中国古文: 仰观宇宙之大,俯察品类之盛,所以游目…

    2023年 5月 17日
  • 福建凤凰单丛这个茶属于什么茶(最像凤凰鸟是什么鸟)

    近日,在福建漳州抓拍到白色的“凤凰”在山谷间飞翔,宛若仙境神鸟。经收藏中国查证,这种“白凤凰”是国家Ⅱ级重点保护野生动物——白鹇。 白鹇属于大型鸡类。雄鸟全长1m~1.2m,雌鸟全…

    2023年 2月 21日
  • 嗓子疼痛持续发烧是新冠吗

    截至11月28日,北京本轮疫情累计确诊万余名新冠肺炎感染者。奥密克戎BF.7变异株是北京本轮疫情的主要毒株。为了及时收治新冠患者,北京各区分别建立方舱医院。感染新冠是什么感受?不同…

    2023年 2月 4日
  • 国际黄金期货价格走势,目前黄金期货未来走势

    在权益类市场震荡之时,黄金期货却一骑绝尘,接连突破近五年高位,出尽风头。 6月25日,黄金市场继续走高,COMEX黄金期货主力一度突破1440美元关口,最高触及1442.90美元/…

    2023年 3月 19日
  • 阴阳师密信答案

    原创总结小笔,本篇共四十六题。 (图为辉夜姬呱简介) 式神篇(3) 1. 剧情中,雪女和大天狗追随的阴阳师是谁? 八百比丘尼 源博雅 ☆黑晴明☆ 2. 剧情中,金鱼姬想要向谁报复?…

    2022年 12月 1日
  • 黑暗物质三部曲与黄金罗盘的关系

    继《指环王》三部曲获得巨大成功之后,新线影业自然而然想要继续开发更多基于奇幻名作改编的电影,2008年的这部《黄金罗盘》就是彰显其野心的重头戏。为此新线影业不惜斥巨资请来妮可·基德…

    2023年 4月 15日
  • 含含糊糊的意思和造句

    曾经有一位长辈,叮咛过这么一句话:“在中国社会,什么时候把含含糊糊和清清楚楚弄明白了,也就差不多了。” 当时年纪轻,实在摸不着底细,不知道其所交代的意思究竟是什么。多年以后,逐渐领…

    2023年 12月 16日
  • 月球与地球引力的关系

    潮汐是在月球、太阳对天体引力作用下所产生的,在万有引力的作用下,月亮对地球上的海水有吸引力,人们把吸引海水涨潮的力叫引潮力,地球表面各地离月亮的远近不一样,所以,各处海水所受的引潮…

    综合百科 2023年 4月 17日
  • 历史上八大山人是干什么的

    一提起中国画家,我们会不由自主地想到齐白石,会想到王冕,还可能会想到顾恺之。除了这些人,你肯定还听过这个名号“八大山人”。这个八大山人图是目前中国古画拍卖市场最火热的画,在一些拍卖…

    2023年 7月 13日