实践篇-自定义弹窗
一 前言
本章节,我们一起来设计一个自定义的弹窗组件,会包含如下知识点:
- 弹窗组件设计;
 - ReactDOM.createPortal 使用;
 - 组件静态方法使用;
 - 不依赖父组件实现挂载/卸载组件。
 
二 设计思路
1 建立目标
要实现的具体功能下:
编写的自定义 Modal 可以通过两种方式调用:
- 第一种通过挂载组件方式,动态设置 visible 属性。
 
<Modal  title={'《React进阶实践指南》'}  visible={visible}  >
    <div> hello,world </div>
</Modal>
- 第二种通过 Modal 静态属性方法,控制 Modal 的显示/隐藏。
 
 Modal.show({ /* 自定义弹窗的显示 */
    content:<p>确定购买《React进阶指南小册》吗</p>,
    title:'《React进阶实践指南》',
    onOk:()=>console.log('点击确定'),
    onCancel:()=>console.log('点击取消'),
    onClose:()=> Modal.hidden() /* 自定义弹窗的隐藏 */
})
如上,Modal.show 控制自定义弹窗的显示,可以通过 Modal.hidden 控制弹窗的隐藏,业务层不需要挂载组件。
其他要求:
- 自定义弹窗要有渐变的动画效果。
 
2 设计思路
1 props的设定
实现的 Modal 组件需要 props 配置项如下。
props 属性 | 属性描述 | 属性类型 | 
|---|---|---|
| visible | 当前 modal 是否显示 | boolean | 
| onOk 回调函数 | 当点击确定按钮触发 | function | 
| onCancel 回调函数 | 当点击取消按钮触发 | function | 
| closeCb 回调函数 | 当弹窗完全关闭后触发 | function | 
| width | 弹窗宽度 | number | 
| okTest | 确定按钮文案 | string | 
| cancelText | 取消按钮文案 | string | 
| title | Modal标题 | string | 
| footer | 自定义底部内容 | React Element | 
| children | Modal 内容(插槽模式) | React Element | 
| content | Modal 内容( props 属性模式) | React Element | 
2 组件之外渲染
需要把弹窗组件渲染到挂载的容器之外,这样不受到父组件的影响。这里可以通过 ReactDOM.createPortal API解决这个问题。
Portal 提供了一种将子节点渲染到存在于父组件以外的 DOM 节点的优秀的方案。createPortal 可以把当前组件或 element 元素的子节点,渲染到组件之外的其他地方。
createPortal 接受两个参数:
ReactDOM.createPortal(child, container)
- 第一个: child 是任何可渲染的 React Element元素。
 - 第二个: container 是一个 DOM 元素。
 
3 不依赖父组件实现挂载/卸载组件
挂载组件
一个 React 应用,可以有多个 root Fiber, 所以可以通过 ReactDOM.render 来实现组件的自由挂载。
卸载组件
上面既然完成了挂载组件,下面需要在隐藏 Modal 的时候去卸载组件。 可以通过 ReactDOM.unmountComponentAtNode 来实现这个功能。
unmountComponentAtNode 从 DOM 中卸载组件,会将其事件处理器和 state 一并清除。 如果指定容器上没有对应已挂载的组件,这个函数什么也不会做。如果组件被移除将会返回 true ,如果没有组件可被移除将会返回 false 。
三 代码实现
1 组件层面
Modal——分配 props ,渲染视图
import Dialog from './dialog'
class Modal extends React.PureComponent{
    /* 渲染底部按钮 */
    renderFooter=()=>{
        const { onOk , onCancel , cancelText , okText, footer  } = this.props
        /* 触发 onOk / onCancel 回调  */
        if(footer && React.isValidElement(footer)) return footer
        return <div className="model_bottom" >
            <div className="model_btn_box" >
                <button className="searchbtn"  onClick={(e)=>{ onOk && onOk(e) }} >{okText || '确定'}</button>
                <button className="concellbtn" onClick={(e)=>{ onCancel && onCancel(e) }} >{cancelText || '取消'}</button>
            </div>
        </div>
    }
    /* 渲染顶部 */
    renderTop=()=>{
        const { title , onClose  } = this.props
        return <div className="model_top" >
            <p>{title}</p>
            <span className="model_top_close"  onClick={()=> onClose && onClose()} >x</span>
        </div>
    }
    /* 渲染弹窗内容 */
    renderContent=()=>{
        const { content , children } = this.props
        return  React.isValidElement(content) ? content
                : children ? children : null
    }
    render(){
        const { visible, width = 500 ,closeCb , onClose  } = this.props
        return <Dialog
            closeCb={closeCb}
            onClose={onClose}
            visible={visible}
            width={width}
               >
           {this.renderTop()}
           {this.renderContent()}
           {this.renderFooter()}
     </Dialog>
    }
}
设计思路:
- Modal 组件的设计实际很简单,就是接收上述的 props 配置,然后分配给 Top, Foot, Content 等每个部分。
 - 这里通过 Dialog 组件,来实现 Modal 的动态显示/隐藏,增加动画效果。
 - 绑定确定 onOk ,取消 onCancel ,关闭 onClose 等回调函数。
 - 通过 PureComponent 做性能优化。
 
Dialog——控制显示隐藏
import React , { useMemo , useEffect ,useState  } from 'react'
import ReactDOM from 'react-dom'
 /* 控制弹窗隐藏以及动画效果 */
 const controlShow = (f1,f2,value,timer)=> {
    f1(value)
    return  setTimeout(()=>{
        f2(value)
    },timer)
}
export default function Dialog(props){
    const { width , visible , closeCb , onClose  } = props
    /* 控制 modelShow 动画效果 */
    const [ modelShow , setModelShow ] = useState(visible)
    const [ modelShowAync , setModelShowAync ] = useState(visible)
    const renderChildren = useMemo(()=>{
        /* 把元素渲染到组件之外的 document.body 上  */
        return ReactDOM.createPortal(
          <div style={{ display:modelShow ? 'block' : 'none'  }} >
              <div className="model_container" style={{ opacity:modelShowAync ? 1 : 0  }}  >
                <div className="model_wrap" >
                    <div  style={{ width:width + 'px'}}  > {props.children} </div>
                </div>
              </div>
              <div  className="model_container mast"  onClick={()=> onClose && onClose()} style={{ opacity:modelShowAync ? 0.6 : 0  }}  />
          </div>,
          document.body
         )
    },[ modelShowAync, modelShow ])
    useEffect(()=>{
        let timer
        if(visible){
            /* 打开弹窗,需要先让 */
           timer = controlShow(setModelShow,setModelShowAync,visible,30)
        }else{
           timer = controlShow(setModelShowAync,setModelShow,visible,1000)
        }
        return function (){
            timer && clearTimeout(timer)
        }
    },[ visible ])
    /* 执行关闭弹窗后的回调函数 closeCb */
    useEffect(()=>{
        !modelShow && typeof closeCb  === 'function' && closeCb()
    },[ modelShow ])
    return renderChildren
设计思路:
需要把元素渲染到组件之外,用 createPortal 把元素直接渲染到 document.body 下,为了防止函数组件每一次执行都触发 createPortal, 所以通过 useMemo 做性能优化。
因为需要渐变的动画效果,所以需要两个变量 modelShow / modelShowAync 来控制显示/隐藏,modelShow 让元素显示/隐藏,modelShowAync 控制动画执行。
- 当弹窗要显示的时候,要先设置 modelShow 让组件显示,然后用 setTimeout 调度让 modelShowAync 触发执行动画。
 - 当弹窗要隐藏的时候,需要先让动画执行,所以先控制 modelShowAync ,然后通过控制 modelShow 元素隐藏,和上述流程相反。
 - 用一个控制器 controlShow 来流畅执行更新任务。
 
2 静态属性方法
对于通过组件的静态方法来实现弹窗的显示与隐藏,流程在上述基础上,要更复杂有一些。
let ModalContainer = null
const modelSysbol = Symbol('$$__model__Container_hidden')
/* 静态属性show——控制 */
Modal.show = function(config){
    /* 如果modal已经存在了,那么就不需要第二次show */
   if(ModalContainer) return
   const props = { ...config , visible: true }
   const container = ModalContainer =  document.createElement('div')
   /* 创建一个管理者,管理moal状态 */
   const manager =  container[modelSysbol] = {
       setShow:null,
       mounted:false,
       hidden(){
          const { setShow } = manager
          setShow && setShow(false)
       },
       destory(){
           /* 卸载组件 */
           ReactDOM.unmountComponentAtNode(container)
          /* 移除节点 */
          document.body.removeChild(container)
          /* 置空元素 */
          ModalContainer = null
       }
   }
   const ModelApp = (props) => {
       const [ show , setShow ] = useState(false)
       manager.setShow = setShow
       const { visible,...trueProps } = props
       useEffect(()=>{
           /* 加载完成,设置状态 */
           manager.mounted = true
           setShow(visible)
        },[])
       return <Modal  {...trueProps} closeCb={() => manager.mounted &&  manager.destory()}  visible={show}  />
   }
   /* 插入到body中 */
   document.body.appendChild(container)
   /* 渲染React元素 */
   ReactDOM.render(<ModelApp  {...props}  />,container)
   return manager
}
/* 静态属性——hidden控制隐藏 */
Modal.hidden = function(){
   if(!ModalContainer) return
   /* 如果存在 ModalContainer 那么隐藏 ModalContainer  */
   ModalContainer[modelSysbol] && ModalContainer[modelSysbol].hidden()
}
export default Modal
接下来,描述一下流程和细节:
- 第一点:因为要通过调用 Modal 的静态属性来实现组件的显示与隐藏。所以用 
Modal.show来控制显示,Modal.hidden来控制隐藏。但是两者要建立起关联,所以通过全局ModalContainer属性,能够隐藏掉Modal.show产生的元素与组件。 - 第二点:如果调用 
Modal.show,首先会创建一个元素容器 container ,用来挂载 Modal 组件,通过 ReactDOM.render 挂载,这里需要把 contianer 插入到 document.body 上。 - 第三点:因为 Modal 组件要动态混入 visible 属性,并且做一些初始化的工作,比如提供隐藏弹窗的方法,所以创建一个 ModelApp 容器组件包裹 Modal。
 - 第四点:因为要在弹窗消失的动画执行后,再统一卸载组件和元素,所以到了本模块难点,就是创建一个 modal manager 管理者,通过 
Symbol('$$__model__Container_hidden')把管理者和容器之间建立起关联。容器下有 hidden 只是隐藏组件,并没有销毁组件,当组件隐藏动画执行完毕,会执行 closeCb 回调函数,在回调函数中再统一卸载元素和组件。 - 第五点:调用
Modal.hidden本质上调用的是 manager 上的 hidden 方法 ,然后执行动画,执行隐藏元素。然后再触发 destory ,用 unmountComponentAtNode 和 removeChild 做一些收尾工作。完成整个流程。 
创建弹窗流程图:
关闭弹窗流程图:
四 验证环节
验证第一种——通过挂载组件方式
/* 挂载方式调用modal */
export default function Index() {
    const [ visible , setVisible ] = useState(false)
    const [ nameShow , setNameShow ] = useState(false)
    const handleClick = () => {
        console.log('点击')
        setVisible(!visible)
        setNameShow(!nameShow)
    }
    /* 防止 Model 的 PureComponent 失去作用 */
    const [ handleClose ,handleOk, handleCancel ] = useMemo(()=>{
        const Ok = () =>  console.log('点击确定按钮')
        const Close = () => setVisible(false)
        const Cancel = () => console.log('点击取消按钮')
        return [ Close , Ok , Cancel  ]
    },[])
    return <div>
        <Modal
            onCancel={handleCancel}
            onClose={handleClose}
            onOk={handleOk}
            title={'《React进阶实践指南》'}
            visible={visible}
            width={700}
        >
           <div className="feel" >
              小册阅读感受: <input placeholder="写下你的感受" />
              {nameShow && <p>作者: 我不是外星人</p>}
           </div>
        </Modal>
        <button onClick={() => {
            setVisible(!visible)
            setNameShow(false)
        }}
        > model show </button>
        <button onClick={handleClick} > model show ( 显示作者 ) </button>
    </div>
}
- 如上就是挂载的方式使用 Modal,注意 Modal 用的是 PureComponent ,父组件是函数组件在给 PureComponent 绑定方法的时候 ,要用 useMemo 或 useCallback 处理。
 
效果:
验证第二种——通过静态属性方式
export default function Index(){
    const handleClick =() => {
        Modal.show({
            content:<p>确定购买《React进阶指南小册》吗</p>,
            title:'《React进阶实践指南》',
            onOk:()=>console.log('点击确定'),
            onCancel:()=>console.log('点击取消'),
            onClose:()=> Modal.hidden()
        })
    }
    return <div>
        <button onClick={() => handleClick()} >静态方式调用,显示modal</button>
    </div>
}
- 这种方式用起来比上一种要简单。流程我就不细说了。
 
效果:
五 总结
本章节的知识点总结:
- 自定义弹窗组件的编写——挂载组件/调用静态属性两种方式。
 - ReactDOM.createPortal 使用。
 - ReactDOM.unmountComponentAtNode 和 ReactDOM.render 实现自由挂载/卸载组件。
 - hooks 的使用与性能优化。