IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

    为什么我更推荐命令式 Modal

    静かな森发表于 2023-10-28 11:39:20
    love 0
    该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/programming/why-i-prefer-imperative-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: 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>
      )
    }

    甚至,你可以在 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.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 中实现了最初的一版。没有借助任何组件库和动画库。

    https://github.com/mx-space/kami/blob/v3.14.7/src/components/universal/Modal/stack.context.tsx

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

    https://github.com/Crossbell-Box/xLog/commit/48aa67e784bc43762a9311b80c88f66903c2af65

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

    https://github.com/Innei/Shiro/blob/main/src/providers/root/modal-stack-provider.tsx

    总结

    优点

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

    缺点

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

    看完了?说点什么呢



沪ICP备19023445号-2号
友情链接