banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

为什么我更推荐命令式 Modal

更新 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。useModalStackpresent 用于唤出一个新的 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>
  )
}
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.