基于 React 封装 API 调用式 Modal 组件

561 阅读2分钟

前言

本文基于 nextUItailwind CSS 实现 API 调用式 Modal 组件的封装。

组件的设计与实现

首先,介绍一下 Modal 组件的目录结构:

image.png

创建上下文对象

如果想了解 Context 更多内容,可以阅读 React Context 官方文档。

根据自身需求,创建 ModalContext 上下文对象:

// Context.tsx
import { createContext, useContext } from 'react'
import type { ButtonProps, ModalProps as _ModalProps } from '@nextui-org/react'

export type ModalFooterParams = {
  ConfirmButton: (props: ButtonProps) => React.ReactNode
  CancelButton: (props: ButtonProps) => React.ReactNode
  onClose: () => void
}

export type ModalFooterRenderer = (params: ModalFooterParams) => React.ReactNode

export type ModalType = 'default' | 'primary' | 'success' | 'warning' | 'danger'

export type ModalProps = {
  /** 模态框类型 */
  type?: ModalType
  /** 模态框标题 */
  title?: React.ReactNode
  /** 模态框内容 */
  content?: React.ReactNode
  /** 自定义底部内容 */
  footer?: ModalFooterRenderer | React.ReactNode
  /** 关闭回调 */
  onClose?: () => void

  /** 确认按钮文本 */
  confirmButtonText?: string
  /** 确认按钮属性 */
  confirmButtonProps?: ButtonProps
  /** 确认按钮图标 */
  confirmButtonIcon?: React.ReactNode
  /** 确认回调 */
  onConfirm?: () => void
  /** 确认前的校验回调 */
  beforeConfirm?: () => boolean
  /** 确认按钮加载状态 */
  isConfirmLoading?: boolean
  /** 取消按钮文本 */
  cancelButtonText?: string
  /** 取消按钮属性 */
  cancelButtonProps?: ButtonProps
  /** 取消按钮图标 */
  cancelButtonIcon?: React.ReactNode
  /** 取消回调 */
  onCancel?: () => void
} & Partial<Omit<_ModalProps, 'title' | 'content'>>

export const ModalContext = createContext<ModalProps>({} as ModalProps)

export const useModalContext = () => {
  return useContext(ModalContext)
}

实现 ModalFooter 组件

实现一个 COLOR_MAP 对象,以定义按钮类型及其对应的主题色。

接下来,将 ConfirmButtonCancelButton 和 ModalFooter 组件的事件系统进行统一集成,通过事件代理机制确保 onConfirm 和 onCancel 自定义事件的正常触发。

// Footer.tsx
import { useState } from 'react'
import {
  Button,
  ButtonProps,
  ModalFooter as _ModalFooter,
  useModalContext as _useModalContext,
} from '@nextui-org/react'
import { useModalContext } from './Context'

export const COLOR_MAP = {
  default: 'bg-black dark:text-black dark:bg-white',
  primary: 'bg-violet-500/80',
  success: 'bg-green-600/80',
  warning: 'bg-amber-500/80',
  danger: 'bg-red-500/80',
}

export function ConfirmButton(props: ButtonProps) {
  const { onClose: _onClose } = _useModalContext()

  const {
    type = 'default',
    confirmButtonText = '确认',
    confirmButtonProps,
    confirmButtonIcon,
    onClose,
    onConfirm,
    beforeConfirm,
    isConfirmLoading,
  } = useModalContext()

  const [isLoading, setIsLoading] = useState(false)

  const onClick = async () => {
    if (confirmButtonProps?.type === 'submit') {
      return
    }
    setIsLoading(!!isConfirmLoading)
    try {
      const isConfirm = await beforeConfirm?.()
      if (isConfirm !== false) {
        await onConfirm?.()
        onClose?.()
        _onClose()
      }
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <Button
      color={type}
      className={`w-max py-2 px-8 rounded-lg flex items-center gap-2 text-white border-slate-400 ${COLOR_MAP[type]}`}
      startContent={confirmButtonIcon}
      isLoading={isLoading}
      onClick={onClick}
      {...props}
      {...confirmButtonProps}>
      {confirmButtonText}
    </Button>
  )
}

export function CancelButton(props: ButtonProps) {
  const { onClose: _onClose } = _useModalContext()

  const {
    cancelButtonText = '取消',
    cancelButtonProps,
    cancelButtonIcon,
    onCancel,
    onClose,
  } = useModalContext()

  const onClick = () => {
    onCancel?.()
    onClose?.()
    _onClose()
  }

  return (
    <Button
      className='rounded-lg bg-transparent border border-gray-400/70 text-gray-400/70 dark:text-white dark:border-white hover:bg-default-100'
      startContent={cancelButtonIcon}
      onClick={onClick}
      {...props}
      {...cancelButtonProps}>
      {cancelButtonText}
    </Button>
  )
}

export function ModalFooter() {
  const { onClose } = _useModalContext()
  const { footer } = useModalContext()

  const defaultFooter = (
    <div className='flex items-center gap-2'>
      <CancelButton />
      <ConfirmButton />
    </div>
  )

  if (typeof footer === 'function') {
    return <_ModalFooter>{footer({ ConfirmButton, CancelButton, onClose })}</_ModalFooter>
  }

  return footer !== null ? (
    <_ModalFooter>{footer || defaultFooter}</_ModalFooter>
  ) : null
}

实现 Modal 组件

Modal 组件通过 ModalContext 集中管理属性和状态。另外,此处对 Modal 组件的关闭逻辑进行了整合,保证 onCancel 和 onClose 回调函数的依次触发。

Modal 组件的渲染逻辑如下:

// index.tsx
import {
  Modal as _Modal,
  ModalBody,
  ModalHeader,
  ModalContent,
  ModalFooter as _ModalFooter,
  useModalContext as _useModalContext,
} from '@nextui-org/react'
import { ModalContext, type ModalProps } from './Context'
import { ModalFooter } from './Footer'
import { withType } from './modal'

export function Modal(props: ModalProps) {
  const {
    title,
    content,
    footer,
    onClose: _onClose,
    children,
    confirmButtonText,
    confirmButtonProps,
    onConfirm,
    beforeConfirm,
    isConfirmLoading,
    cancelButtonText,
    cancelButtonProps,
    onCancel,
    ...restProps
  } = props

  function onClose() {
    onCancel?.()
    _onClose?.()
  }

  return (
    <ModalContext.Provider value={props}>
      <_Modal disableAnimation className='m-auto' onClose={onClose} {...restProps}>
        <ModalContent>
          <ModalHeader>{title}</ModalHeader>
          <ModalBody className='py-0 text-sm'>
            {content || children}
          </ModalBody>
          <ModalFooter />
        </ModalContent>
      </_Modal>
    </ModalContext.Provider>
  )
}

Modal.default = withType('default')
Modal.primary = withType('primary')
Modal.success = withType('success')
Modal.warning = withType('warning')
Modal.danger = withType('danger')

上述代码中,通过向 withType 函数传入 type 参数,即可创建对应类型的 Modal

接下来,对 Modal 组件的创建、销毁和渲染不同类型 Modal 的方法进行封装:

// modal.tsx
import { isValidElement } from 'react'
import { createRoot } from 'react-dom/client'
import { Modal } from './index'
import type { ModalProps, ModalType } from './Context'

function modal(config: ModalProps) {
  const currentConfig = {
    ...config,
    isOpen: true,
    onClose,
  }

  const container = document.createDocumentFragment()
  const root = createRoot(container)

  function render(config: ModalProps) {
    root.render(<Modal {...config} />)
  }

  function onClose() {
    render({
      ...currentConfig,
      isOpen: false,
    })

    setTimeout(function () {
      root.unmount()
    }, 300)
  }

  render(currentConfig)

  return {
    onClose,
  }
}

export function withType(type: ModalType) {
  function _withType(
    content: React.ReactNode | ModalProps,
    config?: Omit<ModalProps, 'content'>
  ): Promise<void> {
    // 判断传入的 content 是否为 ReactNode 或 string
    // 若是,直接视作 content 处理,否则视为 config 对象处理
    const _config = isValidElement(content) || typeof content === 'string'
      ? { ...config, content }
      : content as ModalProps

    return new Promise((resolve, reject) => {
      const onConfirm = async () => {
        await _config.onConfirm?.()
        resolve()
      }

      const onCancel = async () => {
        await _config.onCancel?.()
        reject()
      }

      modal({
        ..._config,
        onConfirm,
        onCancel,
        type,
      })
    })
  }

  return _withType
}

组件的使用方式

传入文本内容

定义 handleClick 函数,随后在函数内部调用 Modal 组件,传入文本内容:

import { type ModalType } from '@/components/Modal/Context'
import { COLOR_MAP } from '@/components/Modal/Footer'
import { Modal } from '@/components/Modal'
import { Button } from '@nextui-org/react'

export default function Home() {
  const colorList: ModalType[] = ['default', 'primary', 'success', 'warning', 'danger']

  const handleClick = (type: ModalType) => {
    Modal[type](`${type} 操作成功!`)
  }

  return (
    <div className='py-12 flex gap-2 items-center justify-center'>
      {colorList.map((color) => (
        <Button
          key={color}
          color={color}
          className={`rounded-md text-white ${COLOR_MAP[color]}`}
          onClick={() => handleClick(color)}
        >
          {color} Click
        </Button>
      ))}
    </div>
  )
}
const handleClick = (type: ModalType) => {
  Modal[type](`${type} 操作成功!`)
}

20241217163645_rec_.gif

传入配置对象

对 handleClick 函数中 Modal 组件的调用形式进行修改,传入一个配置对象:

const handleClick = (type: ModalType) => {
  Modal[type]({
    title: '提示',
    content: `${type} 操作成功!`,
    confirmButtonText: '收到',
    cancelButtonText: '不听',
  })
}

20241217164014_rec_.gif

传入文本内容和配置对象

对 handleClick 函数中 Modal 组件的调用形式进行修改,同时传入文本内容和配置对象:

const handleClick = (type: ModalType) => {
  Modal[type](`${type} 操作成功!`, {
    title: '提示',
    confirmButtonText: '我知道了',
    cancelButtonText: '取消',
  })
}

20241217163508_rec_.gif

最后

欢迎大家提出不足之处或其他建议,一起进步。