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方法,就会新增加一个节点。
这里就要使用到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组件源码给出了答案
源码中,使用了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
文章到这应该告一段落了,...嗯?标题中不是提到封装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组件源码