UI
对话框一般是我们点击按钮弹出的这么一个东西,主要类型有 Alter
, Confirm
及 Modal
, Modal 一般带有半透明的黑色背景。当然外观可参考 AntD 或者 Framework 等。
确定 API
API 方面主要还是要参考同行,因为如果有一天,别人想你用的UI框架时,你的 API 跟他之前常用的又不用,这样就加大了入门门槛,所以API 尽量保持跟现有的差不多。
对话框除了提供显示属性外,还要有点击确认后的回放函数,如:
alert('你好').then(fn) confirm('确定?').then(fn) modal(组件名)
实现
Dialog 源码已经上传到这里。
dialog/dialog.example.tsx, 这里 state ,生命周期使用 React 16.8 新出的 Hook,如果对 Hook 不熟悉可以先看官网文档。
dialog/dialog.example.tsx
import React, {useState} from 'react' import Dialog from './dialog' export default function () { const [x, setX] = useState(false) return ( <div> <button onClick={() => {setX(!x)}}>点击</button> <Dialog visible={x}></Dialog> </div> ) }
dialog/dialog.tsx
import React from 'react' interface Props { visible: boolean } const Dialog: React.FunctionComponent<Props> = (props) => { return ( props.visible ? <div>dialog</div> : null ) } export default Dialog
运行效果
显示内容
上述还有问题,我们 dialog 在组件内是写死的,我们想的是直接通过组件内包裹的内容,如:
// dialog/dialog.example.tsx ... <Dialog visible={x}> <strong>hi</strong> </Dialog> ...
这样写,页面上是不会显示 hi
的,这里 children 属性就派上用场了,我们需要在 dialog 组件中进一步骤修改如下内容:
// dialog/dialog.tsx ... return ( props.visible ? <div> {props.children} </div> : null ) ...
显示遮罩
通常对话框会有一层遮罩,通常我们大都会这样写:
// dialog/dialog.tsx ... props.visible ? <div className="fui-dialog-mask"> <div className="fui-dialog"> {props.children} </div> </div> : null ...
这种结构有个不好的地方就是点击遮罩层的时候要关闭对话框,如果是用这种结构,用户点击任何 div
,都相当于点击遮罩层,所以最好要分开:
// dialog/dialog.tsx ... <div> <div className="fui-dialog-mask"> </div> <div className="fui-dialog"> {props.children} </div> </div> ...
由于 React 要求最外层只能有一个元素, 所以我们多用了一个 div
包裹起来,但是这种方法无形之中多了个 div
,所以可以使用 React 16 之后新出的 Fragment
, Fragment 跟 vue 中的 template 一样,它是不会渲染到页面的。
import React, {Fragment} from 'react' import './dialog.scss'; interface Props { visible: boolean } const Dialog: React.FunctionComponent<Props> = (props) => { return ( props.visible ? <Fragment> <div className="fui-dialog-mask"> </div> <div className="fui-dialog"> {props.children} </div> </Fragment> : null ) } export default Dialog
完善头部,内容及底部
这里不多说,直接上代码
import React, {Fragment} from 'react' import './dialog.scss'; import {Icon} from '../index' interface Props { visible: boolean } const Dialog: React.FunctionComponent<Props> = (props) => { return ( props.visible ? <Fragment> <div className="fui-dialog-mask"> </div> <div className="fui-dialog"> <div className='fui-dialog-close'> <Icon name='close'/> </div> <header className='fui-dialog-header'>提示</header> <main className='fui-dialog-main'> {props.children} </main> <footer className='fui-dialog-footer'> <button>ok</button> <button>cancel</button> </footer> </div> </Fragment> : null ) } export default Dialog
从上述代码我们可以发现我们写样式的名字时候,为了不被第三使用覆盖,我们自定义了一个 fui-dialog
前缀,在写每个样式名称时,都要写一遍,这样显然不太合理,万一哪天我不用这个前缀时候,每个都要改一遍,所以我们需要一个方法来封装。
咱们可能会写这样方法:
function scopedClass(name) { return `fui-dialog-${name}` }
这样写不行,因为我们 name 可能不传,这样就会多出一个 -
,所以需要进一步的判断:
function scopedClass(name) {
return fui-dialog-${name ? '-' + name : ''}
}
那还有没有更简洁的方法,使用 filter
方法:
function scopedClass(name ?: string) { return ['fui-dialog', name].filter(Boolean).join('-') }
调用方式如下:
....
<div className={scopedClass('mask')}>
<div className={scopedClass('close')}>
<header className={scopedClass('header')}>提示
<main className={scopedClass('main')}>
{props.children}
<footer className={scopedClass('footer')}>
ok
cancel
...
大家在想法,这样写是有问题,每个组件都写一个函数吗,如果 Icon 组件,我还需要写一个 fui-icon
, 解决方法是把 前缀
当一个参数,如:
function scopedClass(name ?: string) { return ['fui-dialog', name].filter(Boolean).join('-') }
调用方式如下:
className={scopedClass('fui-dialog', 'mask')}
这样写,还不如直接写样式,这种方式是等于白写了一个方法,那怎么办?这就需要高阶函数出场了。实现如下:
function scopeClassMaker(prefix: string) { return function (name ?: string) { return [prefix, name].filter(Boolean).join('-') } } const scopedClass = scopeClassMaker('fui-dialog')
scopeClassMaker
函数是高级函数,返回一个带了 prefix
参数的函数。
事件处理
在写事件处理之前,我们 Dialog 需要接收一个 buttons
属性,就是显示的操作按钮并添加事件:
// dialog/dialog.example.tsx ... <Dialog visible={x} buttons = { [ <button onClick={()=> {setX(false)}}>1</button>, <button onClick={()=> {setX(false)}}>2</button>, ] }> <div>hi</div> </Dialog> ...
咱们看到这个,第一反应应该是觉得这样写很麻烦,我写个 dialog, visible要自己,按钮要自己,连事件也要自己写。请接受这种设定。虽然麻烦,但非常的好理解。这跟 Vue 的理念是不太一样的。当然后面会进一步骤优化。
组件内渲染如下:
<footer className={sc('footer')}> { props.buttons } </footer>
运行起来你会发现有个警告:
主要是说我们渲染数组时,需要加个 key
,解决方法有两种,就是不要使用数组方式,当然这不治本,所以这里 React.cloneElemen
出场了,它可以克隆元素并添加对应的属性值,如下:
{ props.buttons.map((button, index) => { React.cloneElement(button, {key: index}) }) }
对应的点击关闭事件相对容易这边就不讲了,可以自行查看源码。
接下来来看一个样式的问题,首先先给出我们遮罩的样式:
.fui-dialog { position: fixed; background: white; min-width: 20em; z-index: 2; border-radius: 4px; top: 50%; left: 50%; transform: translate(-50%, -50%); &-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: fade_out(black, 0.5); z-index: 1; } .... 以下省略其它样式 }
我们遮罩 .fui-dialog-mask
使用 fixed
定位感觉是没问题的,那如果在调用 dialog 同级在加以下这么元素:
<div style={{position:'relative', zIndex: 10, background:'#fff'}}>666</div> <button onClick={() => {setX(!x)}}>点击</button> <Dialog visible={x}> ... </Dialog>
运行效果:
发现遮罩并没有遮住 666 的内容。这是为什么?
看结构也很好理解,遮罩元素与 666 是同级结构,且层级比 666 低,当然是覆盖不了的。那咱们可能就会这样做,给.fui-dialog-mask
设置一个 zIndex
比它大的呗,如 9999
。
效果:
恩,感觉没问题,这时我们在 Dialog 组件在嵌套一层 zIndex 为 9
的呢,如:
<div style={{position:'relative', zIndex: 9, background:'#fff'}}> <Dialog visible={x}> ... </Dialog> </div>
运行效果如下:
发现,父元素被压住了,里面元素 zIndex 值如何的高,都没有效果。
那这要怎么破?答案是不要让它出现在任何元素的里面,这怎么可能呢。这里就需要引出一个神奇的 API了。这个 API 叫做 传送门(portal)。
用法如下:
return ReactDOM.createPortal( this.props.children, domNode );
第一个参数就是你的 div,第二个参数就是你要去的地方。
import React, {Fragment, ReactElement} from 'react' import ReactDOM from 'react-dom' import './dialog.scss'; import {Icon} from '../index' import {scopedClassMaker} from '../classes' interface Props { visible: boolean, buttons: Array<ReactElement>, onClose: React.MouseEventHandler, closeOnClickMask?: boolean } const scopedClass = scopedClassMaker('fui-dialog') const sc = scopedClass const Dialog: React.FunctionComponent<Props> = (props) => { const onClickClose: React.MouseEventHandler = (e) => { props.onClose(e) } const onClickMask: React.MouseEventHandler = (e) => { if (props.closeOnClickMask) { props.onClose(e) } } const x = props.visible ? <Fragment> <div className={sc('mask')} onClick={onClickMask}> </div> <div className={sc()}> <div className={sc('close')} onClick={onClickClose}> <Icon name='close'/> </div> <header className={sc('header')}>提示</header> <main className={sc('main')}> {props.children} </main> <footer className={sc('footer')}> { props.buttons.map((button, index) => { React.cloneElement(button, {key: index}) }) } </footer> </div> </Fragment> : null return ( ReactDOM.createPortal(x, document.body) ) } Dialog.defaultProps = { closeOnClickMask: false } export default Dialog
运行效果:
当然这样,如果 Dialog 层级比同级的 zIndex 小的话,还是覆盖不了。 那 zIndex
一般设置成多少比较合理。一般 Dialog 这层设置成 1
, mask 这层设置成2
。定的越小越好,因为用户可以去改。
zIndex 的管理
zIndex 管理一般就是前端架构师要做的了,根据业务产景来划分,如广告肯定是要在页面最上面,所以 zIndex 一般是属于最高级的。
便利的 API 之 Alert
上述我们使用 Dialog 组件调用方式比较麻烦,写了一堆,有时候我们想到使用 alert 直接弹出一个对话框这样简单方便。如
<h1>example 3</h1> <button onClick={() => alert('1')}>alert</button>
我们想直接点击 button ,然后弹出我们自定义的对话框内容为1 ,需要在 Dialog 组件内我们需要导出一个 alert
方法,如下:
// dialog/dialog.tsx ... const alert = (content: string) => { const component = <Dialog visible={true} onClose={() => {}}> {content} </Dialog> const div = document.createElement('div') document.body.append(div) ReactDOM.render(component, div) } export {alert} ...
运行效果:
但有个问题,因为对话框的 visible 是由外部传入的,且 React 是单向数据流的,在组件内并不能直接修改 visible,所以在 onClose 方法我们需要再次渲染一个新的组件,并设置新组件 visible
为 ture
,覆盖原来的组件:
... const alert = (content: string) => { const component = <Dialog visible={true} onClose={() => { ReactDOM.render(React.cloneElement(component, {visible: false}), div) ReactDOM.unmountComponentAtNode(div) div.remove() }}> {content} </Dialog> const div = document.createElement('div') document.body.append(div) ReactDOM.render(component, div) } ..
便利的 API 之 confirm
confirm 调用方式:
<button onClick={() => confirm('1', ()=>{}, ()=> {})}>confirm</button>
第一个参数是显示的内容,每二个参数是确认的回调,第三个参数是取消的回调函数。
实现方式:
const confirm = (content: string, yes?: () => void, no?: () => void) => { const onYes = () => { ReactDOM.render(React.cloneElement(component, {visible: false}), div) ReactDOM.unmountComponentAtNode(div) div.remove() yes && yes() } const onNo = () => { ReactDOM.render(React.cloneElement(component, {visible: false}), div) ReactDOM.unmountComponentAtNode(div) div.remove() no && no() } const component = ( <Dialog visible={true} onClose={() => { onNo()}} buttons={[<button onClick={onYes}>yes</button>, <button onClick={onNo}>no</button> ]} > {content} </Dialog>) const div = document.createElement('div') document.body.appendChild(div) ReactDOM.render(component, div) }
事件处理跟 Alter 差不多,唯一多了一步就是 confirm
当点击 yes
或者 no
的时候,如果外部有回调就需要调用对应的回调函数。
便利的 API 之 modal
modal 调用方式:
<button onClick={() => {modal(<h1>你好</h1>)}}>modal</button>
modal 对应传递的内容就不是单单的文本了,而是元素。
实现方式:
const modal = (content: ReactNode | ReactFragment) => { const onClose = () => { ReactDOM.render(React.cloneElement(component, {visible: false}), div) ReactDOM.unmountComponentAtNode(div) div.remove() } const component = <Dialog onClose={onClose} visible={true}> {content} </Dialog> const div = document.createElement('div') document.body.appendChild(div) ReactDOM.render(component, div) }
注意,这边的 content 类型。
运行效果:
这还有个问题,如果需要加按钮呢,可能会这样写:
<button onClick={() => {modal(<h1> 你好 <button>close</button></h1> )}}>modal</button>
这样是关不了的,因为 Dialog 是封装在 modal
里面的。如果要关,必须控制 visible
,那很显然我从外面控制不了里面的 visible
,所以这个 button
没有办法把这个 modal
关掉。
解决方法就是使用闭包,我们可以在 modal 方法里面把 close 方法返回:
const modal = (content: ReactNode | ReactFragment) => { const onClose = () => { ReactDOM.render(React.cloneElement(component, {visible: false}), div) ReactDOM.unmountComponentAtNode(div) div.remove() } const component = <Dialog onClose={onClose} visible={true}> {content} </Dialog> const div = document.createElement('div') document.body.appendChild(div) ReactDOM.render(component, div) return onClose; }
最后多了一个 retrun onClose,由于闭包的作用,外部调用返回的 onClose 方法可以访问到内部变量。
调用方式:
const openModal = () => { const close = modal(<h1>你好 <button onClick={() => close()}>close</button> </h1>) } <button onClick={openModal}>modal</button>
重构 API
在重构之前,我们先要抽象 alert, confirm, modal 中各自的方法:
从表格可以看出,modal 与其它两个只多了一个 retrun api,其实其它两个也可以返回对应的 Api,只是我们没去调用而已,所以补上:
这样一来,这三个函数从抽象层面上来看是类似的,所以这三个函数应该合成一个。
首先抽取公共部分,先取名为x
,内容如下:
const x= (content: ReactNode, buttons ?:Array<ReactElement>, afterClose?: () => void) => { const close = () => { ReactDOM.render(React.cloneElement(component, {visible: false}), div) ReactDOM.unmountComponentAtNode(div) div.remove() afterClose && afterClose() } const component = <Dialog visible={true} onClose={() => { close(); afterClose && afterClose() }} buttons={buttons} > {content} </Dialog> const div = document.createElement('div') document.body.append(div) ReactDOM.render(component, div) return close }
alert 重构后的代码如下:
const alert = (content: string) => { const button = <button onClick={() => close()}>ok</button> const close = x(content, [button]) }
confirm 重构后的代码如下:
const confirm = (content: string, yes?: () => void, no?: () => void) => { const onYes = () => { close() yes && yes() } const onNo = () => { close() no && no() } const buttons = [ <button onClick={onYes}>yes</button>, <button onClick={onNo}>no</button> ] const close = modal(content, buttons, no) }
modal 重构后的代码如下:
const modal = (content: ReactNode | ReactFragment) => { return x(content) }
最后发现其实 x
方法就是 modal
方法,所以更改 x
名为 modal
,删除对应的 modal
定义。
总结
scopedClass 高阶函数的使用
传送门 portal
动态生成组件
闭包传 API
本组件为使用优化样式,如果有兴趣可以自行优化,本节源码已经上传至这里中的lib/dialog
。