为什么我更推荐命令式 Modal

Modal 模态对话框。

在中台业务中非常常见。

组件库中一般都会内置这类组件,最为参见的声明式 Modal 定义。

例如 Antd 5 中的声明式 Modal 是这样定义的。

tsx 复制代码
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 显示。不仅写起来复杂,后期维护起来也很困难。

业务越积越多,后面你的页面上可能是这样的。

tsx 复制代码
<>
  <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 到外部。像这样:

tsx 复制代码
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 的状态还是在父组件顶层。导致父组件状态堆积越来越多。

tsx 复制代码
const App: React.FC = () => {
  const [isModalOpen, setIsModalOpen] = useState(false)
  const [isModalOpen2, setIsModalOpen2] = useState(false)
  const [isModalOpen3, setIsModalOpen3] = useState(false)
  // ....
}

然后你思来想去,直接把 Modal 和 Button 抽离到一起。

tsx 复制代码
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 拆了。像这样:

tsx 复制代码
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 中下放这个状态。

tsx 复制代码
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,在 Antd 5 中是这样的。

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

:::note

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

:::

总体思路,我们需要在应用顶层使用一个 Context 来存储所有 Modal 的状态。当 Modal 使用 present 时,创建一个 Modal 实例记录到 Context 中。当 Modal 被关闭时,销毁 Modal 实例。所以在顶层 ModalStack 中的状态应该只包含现在渲染的 Modal 实例。最大化节省内存资源。

接下来我使用 Antd Modal + Jotai 的进行实现。其他类似组件实现方式基本一致。

首先,我们实现 ModalStack。

tsx 复制代码
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。

tsx 复制代码
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。

tsx 复制代码
type ModalContentProps = {
  dismiss: () => void
}

type ModalProps = {
  id?: string

  content: ReactNode | ((props: ModalContentProps) => ReactNode)
} & Omit<AntdModalProps, 'open' | 'content'>

<ModalImpl /> 的实现是非常简单的,在此之前,我们先定义一下 ModalActionContext,后续可以在 Modal 中直接调用使用。

tsx 复制代码
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

diff 复制代码
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 />:

tsx 复制代码
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 />。

tsx 复制代码
const App = document.getElementById('root')
const Root: FC = () => {
  return (
    <div>
      <ModalStack />
    </div>
  )
}

然后像这样使用:

diff 复制代码
  <div>
      <ModalStack />
+      <Page />
  </div>
tsx 复制代码
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。

tsx 复制代码
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 外部使用。

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

从声明式迁移到命令式

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

diff 复制代码
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。

tsx 复制代码
// modal1.tsx
import 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 等)来实现。这增加了一定的复杂性,并可能导致状态管理分散于不同的系统或框架中。

原文发布于:

innei.in/posts/progr...

相关推荐
福兮说3 小时前
前端下载大文件,点了按钮半天没反应:fetch + blob 的三个问题和三种替代写法
前端·javascript·性能优化·文件下载
GreenTea4 小时前
我把 Agent 的 while 循环拆掉了:一种你可能没想到的 Agent 架构
前端·后端·架构
科技观察哨4 小时前
六足平台选型与纳米定位系统集成:HEB-640六自由度位移台在半导体光刻对准中的参数边界与国产替代评估
前端·人工智能
落魄大学生之流水线上谋生计4 小时前
Remotion:用 React 写视频,让代码成为剪辑师
react.js
niucloud-admin4 小时前
JAVA V6 多商户商城 开发文档——web前端
前端
颜进强5 小时前
16 · NestJS LifecycleEvents 生命周期事件:五个钩子、三条边界,和 rag-server 的优雅停机
前端·后端·ai编程
今年下半年6 小时前
【VUE】整合腾讯地图、自定义区域边界、村委名称及资产统计(放大显示资产点位)
前端·javascript·vue.js
郑州光合科技余经理6 小时前
同城电商系统:库存变更怎么同步到订单
java·开发语言·前端·后端·uni-app·php·ai编程
前端snow8 小时前
ai agent --- DeepAgents 中间件
前端
data analyse 4568 小时前
报表能自定义的国内统计工具有哪些
前端·数据分析