如何优雅的封装Toast,Popup,Modal组件?

1,323 阅读6分钟

Toast,Popup,Modal我们项目中经常使用的文件,如何才能优雅而不失礼貌的封装起来呢?今天就由六冰和大家聊聊封装组件那些事。

近期,由于开发一个新的h5项目,项目使用的是react18,需要封装自己的组件,但又不想使用antd-mobile组件库,比如想给Toast改样式,想使用自己的风格样式。 首先来看看简单的Toast组件 这里直接引入代码

import cx from 'classnames'
import React, { useEffect, useRef, useState } from 'react'

import './style.scss'

export interface ToastProps {
  /** 轻提示内容 */
  title: string
  /**
   * @description: 是否显示
   */
  visible?: boolean
  /**
   * @description: 关闭动画结束后触发的回调函数
   */
  afterClose?: () => void
  /** 是否有遮罩层 */
  mask?: boolean
  /** 轻提示持续显示的时间 */
  duration?: number
}

const Toast: React.FC<ToastProps> = props => {
  const { duration, title, mask, afterClose, visible } = props
  const [show, setShow] = useState(false)
  const timer = useRef<NodeJS.Timeout>()

  const clearTimer = (): void => {
    if (timer.current) {
      clearTimeout(timer.current)
      timer.current = undefined
    }
  }
  useEffect(() => {
    setShow(!!visible)
  }, [visible])

  useEffect(() => {
    if (duration && show) {
      timer.current = setTimeout(() => {
        setShow(false)
        if (afterClose) afterClose()
        clearTimer()
      }, duration)
    }
  }, [duration, show, afterClose])

  return show ? (
    <div className={cx('hui-toast-box', { mask: !!mask })}>
      <div className='toast'>
        <div className='text'>{title}</div>
      </div>
    </div>
  ) : null
}

Toast.defaultProps = {
  duration: 1500,
  mask: false,
  visible: false,
  afterClose: undefined,
}

export default Toast

最初的想法就是以上代码,使用组件直接控制Toast展示,因为Toast是需要visible控制所以需要自己手动控制Toast的visible属性,并且需要在afterClose中添加关闭Toast的事件函数,并且想页面只有一个Toast还需要把组件提示文案清空,这样就很不合理。 如下使用:

import Toast from '@/components/Toast'
import cx from 'classnames'
import { useState } from 'react'

import './style.scss'

const classPrefix = 'sku-select-card'



const SkuSelectCard: React.FC<> = () => {
  const [invalidText, setInvalidText] = useState('')
  const [isInvalid, setIsInvalid] = useState(false)
  
  const clearToastStatus = () => {
    setInvalidText('')
    setIsInvalid(false)
  }

  return (
    <div id={skuGroup.uid}>
      <Toast
        title={invalidText}
        visible={isInvalid}
        duration={2000}
        afterClose={clearToastStatus}
      />
    </div>
  )
}

export default SkuSelectCard

于是乎,我就在疑问:为什么antd-mobile能够实现Toast.show('文案')来展示Toast呢,看了之后终于明白Toast组件优雅实现的必须点:

  • 第一个是需要手动创建一个div节点,并且添加到document.body
  • 第二是要借助ReactDom.createRoot(新创建的div节点).render (Toast组件)[React18以下使用ReactDom.render<Toast组件,新创建的div节点>]

那么就开始来写代码吧:

import Toast from '@/components/Toast'
import ReactDom from 'react-dom/client'

const show = (title:string)=>{
      const containerDiv = document.createElement('div')
      document.body.appendChild(containerDiv)
      ReactDom.createRoot(containerDiv).render(
        <Toast visible={true} title={title} />,
      )
}

这样我们就实现了通过show方法控制Toast展示,但是通过调用show你会方法,每调用一次show方法,就会新增加一个节点。

截屏2022-08-06 上午11.45.14.png

这里就要使用到ReactDom的unmount方法了:

  • 首先通过ReactDom.createRoot(新创建的div节点).unmount()将节点与组件的关联取消:删除div的内容。(react18以下通过ReactDOM.unmountComponentAtNode(新创建的div节点))
  • 新创建的div节点.parentNode?.removeChild(新创建的div节点),删除div节点
import Toast from '@/components/Toast'
import ReactDom from 'react-dom/client'

const containerDiv = document.createElement('div')
 
const show = (title:string)=>{
      document.body.appendChild(containerDiv)
      ReactDom.createRoot(containerDiv).render(
        <Toast visible={true} title={title} />,
      )
}

const hide = () => {
    ReactDom.createRoot(containerDiv).unmount()
    containerDiv.parentNode?.removeChild(containerDiv)
  }
// 项目使用
const emailValueChange = () => {
    show({title:'sadas'})
    setTimeout(()=>{hide()},1000)
  }

于是乎,我开始高高兴兴的在项目中去用了,但是随即就发现了问题

  • 想控制时间都需要自己调setTimeout方法
  • 存在多个setTimeout

于是便开始找解决的办法,准备自己封装一个延时显示的方法

import Toast from '@/components/Toast'
import ReactDom from 'react-dom/client'


 
const info = ({title,duration=1500}:{title:string,duration:number}) => {
    const containerDiv = document.createElement('div')
    document.body.appendChild(containerDiv)
    if (timer) {
      window.clearTimeout(timer)
    }
    timer = window.setTimeout(hide, duration)
    // 这里默认传值visible
    ReactDom.createRoot(containerDiv).render(
      <Toast visible={true} title={title} />,
    )
}


// 项目使用
const emailValueChange = () => {
    info({title:'sadas'})
  }

完成以后发现可以使用了,但是还是觉得不够优雅,show的问题也是会存在的,点击show应该有一个清空之前的节点再创建新节点的过程,那怎么办呢,看了antd-mobile的Toast组件源码给出了答案

截屏2022-08-06 下午12.46.50.png 源码中,使用了containers缓存了之前的节点,并在使用show时,使用hide清空了containers所有节点。这样我们就找到了思路

import Toast from '@/components/Toast'
import ReactDom from 'react-dom/client'

  const containers = [] as HTMLDivElement[]

  /**
   * @description: 去除对应的新增节点
   * @param {HTMLDivElement} container
   */

  const unmount = (container: HTMLDivElement) => {
    if (!container) return
    ReactDom.createRoot(container).unmount()
    container.parentNode?.removeChild(container)
  }

  /**
   * @description: 隐藏所有的属性
   */

  const hide = () => {
    while (containers.length > 0) {
      const container = containers.pop()
      if (!container) break
      unmount(container)
    }
  }
 const show = (title:string)=>{
      document.body.appendChild(containerDiv)
      hide()
      containers.push(containerDiv)
      ReactDom.createRoot(containerDiv).render(
        <Toast visible={true} title={title} />,
      )
}

// 项目使用
const emailValueChange = () => {
     show('sadas')
  }

不管调用多少次show方法,项目中只会有一个div

截屏2022-08-06 下午1.01.50.png

文章到这应该告一段落了,...嗯?标题中不是提到封装Popup,Modal组件嘛,嗯?,好像是这么回事,那怎么封装其它组件呢?这时候就需要用到高阶组件了,根据上面的封装Toast组件的经验来封装高阶组件,高阶组件以Toast,Popup,Modal或者其它组件为参数,输出{show,hide,info},使用高阶组件,内部自动形成闭包环境,缓存containter变量。代码如下:

import React from 'react'
import ReactDom from 'react-dom/client'

type DomShowHideProps = <T>(ReactComponent: React.ComponentType<T>) => {
  /**
   * @description: 组件的显示
   */
  show: (props: T) => void
  /**
   * @description: 组件隐藏
   */
  hide: () => void
  /**
   * @description: 组件在展示一段延时后消失
   */
  info: (props: T & { duration?: number }) => void
}
const DomShowHide: DomShowHideProps = ReactComponent => {
  const rootArr = [] as ReactDom.Root[]
  const containers = [] as HTMLDivElement[]
  let timer: NodeJS.Timeout

  /**
   * @description: 去除对应的新增节点
   * @param {HTMLDivElement} container
   */
  const unmount = (root: ReactDom.Root, container: HTMLDivElement) => {
    if (!container || !root) return
    root.unmount()
    container.parentNode?.removeChild(container)
  }

  /**
   * @description: 隐藏所有的属性
   */
  const hide = () => {
    while (containers.length > 0) {
      const container = containers.pop()
      const root = rootArr.pop()
      if (!container || !root) break
      unmount(root, container)
    }
  }

  return {
    show: props => {
      const containerDiv = document.createElement('div')
      document.body.appendChild(containerDiv)
      hide()
      containers.push(containerDiv)
      const createdRoot = ReactDom.createRoot(containerDiv)
      rootArr.push(createdRoot)
      // 这里默认传值visible
      createdRoot.render(<ReactComponent visible={true} {...props} />)
    },
    hide,
    info: props => {
      const containerDiv = document.createElement('div')
      document.body.appendChild(containerDiv)
      hide()
      containers.push(containerDiv)
      const { duration = 1500 } = props
      if (timer) {
        clearTimeout(timer)
      }
      timer = setTimeout(hide, duration)
      const createdRoot = ReactDom.createRoot(containerDiv)
      rootArr.push(createdRoot)
      // 这里默认传值visible
      createdRoot.render(<ReactComponent visible={true} {...props} />)
    },
  }
}

export default DomShowHide


再次优化:之前的hoc由于直接创造和销毁dom,这样就导致无法实现动画,为了优化体验,在dom创建的过程中封装一个组件,销毁过程中给一个定时器,确保组件的动画展示和动画隐藏,具体代码如下:

import React, { useEffect, useImperativeHandle, useState } from 'react'
import ReactDom from 'react-dom/client'

import './style.scss'

export type HDomShowHideProps = <T>(ReactComponent: React.ComponentType<T>) => {
  /**
   * @description: 组件的显示
   */
  show: (props: T) => void
  /**
   * @description: 组件隐藏
   */
  hide: () => void
  /**
   * @description: 组件在展示一段延时后消失
   */
  info: (props: T & { duration?: number }) => void
}
const HDomShowHide: HDomShowHideProps = ReactComponent => {
  const rootArr = [] as ReactDom.Root[]
  const containers = [] as HTMLDivElement[]
  const refArr = [] as React.RefObject<{
    handleClose: () => void
  }>[]
  let timer: NodeJS.Timeout

  /**
   * @description: 去除对应的新增节点
   * @param {HTMLDivElement} container
   */
  const unmount = (root: ReactDom.Root, container: HTMLDivElement) => {
    if (!container || !root) return
    root.unmount()
    container.parentNode?.removeChild(container)
  }

  /**
   * @description: 隐藏所有的属性
   */
  const hide = () => {
    while (containers.length > 0) {
      const container = containers.pop()
      const root = rootArr.pop()
      const ref = refArr.pop()
      ref?.current?.handleClose()
      if (!container || !root) break
      setTimeout(() => {
        unmount(root, container)
      }, 100)
    }
  }

  return {
    show: props => {
      const containerDiv = document.createElement('div')
      if ((props as unknown as { loading: boolean })?.loading) {
        containerDiv.className = 'h-show-hide-dom-container-div'
      }

      document.body.appendChild(containerDiv)
      hide()
      containers.push(containerDiv)
      const createdRoot = ReactDom.createRoot(containerDiv)
      rootArr.push(createdRoot)
      const SureReactComponent: React.ForwardRefRenderFunction<{
        handleClose: () => void
      }> = (componentProps, componentRef) => {
        const [visible, setVisible] = useState(false)
        useEffect(() => {
          // 这里处理是为了展示开启动画 false置为true 才会有开启动画
          setVisible(true)
        }, [])
        // 这里处理是为了展示关闭动画 true置为false 才会有关闭动画
        const handleClose = () => {
          setVisible(false)
        }
        useImperativeHandle(
          componentRef,
          () => ({
            handleClose,
          }),
          [],
        )
        return <ReactComponent visible={visible} {...props} />
      }
      const RefReactComponent = React.forwardRef(SureReactComponent)
      const componentRef = React.createRef<{ handleClose: () => void }>()
      refArr.push(componentRef)
      // 这里默认传值visible
      createdRoot.render(<RefReactComponent ref={componentRef} />)
    },
    hide,
    info: props => {
      const containerDiv = document.createElement('div')
      if ((props as unknown as { loading: boolean })?.loading) {
        containerDiv.className = 'h-show-hide-dom-container-div'
      }
      document.body.appendChild(containerDiv)
      hide()
      containers.push(containerDiv)
      const { duration = 1500 } = props
      if (timer) {
        clearTimeout(timer)
      }
      timer = setTimeout(hide, duration)
      const createdRoot = ReactDom.createRoot(containerDiv)
      rootArr.push(createdRoot)
      const SureReactComponent: React.ForwardRefRenderFunction<{
        handleClose: () => void
      }> = (componentProps, componentRef) => {
        const [visible, setVisible] = useState(false)
        useEffect(() => {
          // 这里处理是为了展示开启动画 false置为true 才会有开启动画
          setVisible(true)
        }, [])
        // 这里处理是为了展示关闭动画 true置为false 才会有关闭动画
        const handleClose = () => {
          setVisible(false)
        }
        useImperativeHandle(
          componentRef,
          () => ({
            handleClose,
          }),
          [],
        )
        return <ReactComponent visible={visible} {...props} />
      }
      const RefReactComponent = React.forwardRef(SureReactComponent)
      const componentRef = React.createRef<{ handleClose: () => void }>()
      refArr.push(componentRef)
      // 这里默认传值visible
      createdRoot.render(<RefReactComponent ref={componentRef} />)
    },
  }
}

export default HDomShowHide

使用:

const { show,hide,info} = DomShowHide(HToast)

show,info参数为包装组件的props对象,info会多一个duration延时消失时间。 每调用一次DomShowHideHoc自动创建一个闭包环境,可以通过多次调用同时显示多个组件

so,是不是优雅的封装好了Toast,Popup,Modal组件?

文章参考链接: antd-mobile组件源码