《精通React/Vue組件設(shè)計(jì)》之手把手實(shí)現(xiàn)一個(gè)輕量級可擴(kuò)展的模態(tài)框(Modal)組件
前言
本文是筆者寫組件設(shè)計(jì)的第九篇文章, 今天帶大家實(shí)現(xiàn)一個(gè)輕量級且可靈活配置組合的模態(tài)框(Modal)組件, 該組件在諸如Antd或者elementUI等第三方組件庫中都會(huì)出現(xiàn),主要用來提供系統(tǒng)的用戶反饋。
之所以會(huì)寫組件設(shè)計(jì)相關(guān)的文章,是因?yàn)樽鳛橐幻岸藘?yōu)秀的前端工程師,面對各種繁瑣而重復(fù)的工作,我們不應(yīng)該按部就班的去"辛勤勞動(dòng)",而是要根據(jù)已有前端的開發(fā)經(jīng)驗(yàn),總結(jié)出一套自己的高效開發(fā)的方法。
[筆記]前端組件的一般分類:
- 通用型組件: 比如Button, Icon等。
- 布局型組件: 比如Grid, Layout布局等。
- 導(dǎo)航型組件: 比如面包屑Breadcrumb, 下拉菜單Dropdown, 菜單Menu等。
- 數(shù)據(jù)錄入型組件: 比如form表單, Switch開關(guān), Upload文件上傳等。
- 數(shù)據(jù)展示型組件: 比如Avator頭像, Table表格, List列表等。
- 反饋型組件: 比如Progress進(jìn)度條, Drawer抽屜, Modal對話框等。
- 其他業(yè)務(wù)類型
所以我們在設(shè)計(jì)組件系統(tǒng)的時(shí)候可以參考如上分類去設(shè)計(jì),該分類也是antd, element, zend等主流UI庫的分類方式。
正文
在開始組件設(shè)計(jì)之前希望大家對css3和js有一定的基礎(chǔ),并了解基本的react/vue語法.我們先來解構(gòu)一下Modal組件, 一個(gè)Modal分為以下幾個(gè)部分:
每一個(gè)區(qū)塊都可以自定義配置, 也可以組合其他組件.實(shí)現(xiàn)后的組件效果:
1、組件設(shè)計(jì)思路
按照之前筆者總結(jié)的組件設(shè)計(jì)原則,我們第一步是要確認(rèn)需求. 模態(tài)框(Modal)組件一般會(huì)有如下需求點(diǎn):
- 能控制Modal主體的樣式
- 提供Modal完全關(guān)閉后的回調(diào)
- 能控制取消按鈕文字和樣式
- 能控制確認(rèn)按鈕文字和樣式
- 控制modal展示的位置
- 控制是否顯示右上角的關(guān)閉按鈕
- 可以配置自定義關(guān)閉圖標(biāo)
- 配置關(guān)閉時(shí)是否銷毀Modal里的子元素
- 自定義模態(tài)框底部內(nèi)容
- 控制是否支持鍵盤esc關(guān)閉
- 控制是否展示遮罩
- 控制點(diǎn)擊蒙層是否允許關(guān)閉
- 自定義遮罩樣式
- 自定義標(biāo)題
- 控制對話框是否可見
- 自定義對話框?qū)挾?/strong>
- 暴露點(diǎn)擊遮罩層或右上角叉或取消按鈕的回調(diào)
- 提供點(diǎn)擊確定回調(diào)
需求收集好之后,作為一個(gè)有追求的程序員, 會(huì)得出如下線框圖:
對于react選手來說,如果沒用typescript,建議大家都用PropTypes, 它是react內(nèi)置的類型檢測工具,我們可以直接在項(xiàng)目中導(dǎo)入. vue有自帶的屬性檢測方式,這里就不一一介紹了。
2、基于react實(shí)現(xiàn)一個(gè)Modal組件
(1)Modal組件框架設(shè)計(jì)
首先我們先根據(jù)需求將組件框架寫好,這樣后面寫業(yè)務(wù)邏輯會(huì)更清晰:
import PropTypes from 'prop-types'
import './index.less'
/**
* Modal Modal組件
* @param {afterClose} func Modal完全關(guān)閉后的回調(diào)
* @param {bodyStyle} object Modal body的樣式
* @param {cancelText} string|ReactNode 取消按鈕文字
* @param {centered} bool 居中展示Modal
* @param {closable} bool 是否展示右上角的關(guān)閉按鈕
* @param {closeIcon} ReactNode 自定義關(guān)閉圖標(biāo)
* @param {destroyOnClose} bool 關(guān)閉時(shí)銷毀Modal里的子元素
* @param {footer} null|ReactNode 底部內(nèi)容,當(dāng)不需要底部默認(rèn)按鈕時(shí),可以設(shè)置為footer={null}
* @param {keyboard} bool 是否支持鍵盤的esc鍵退出
* @param {mask} bool 是否展示遮罩
* @param {maskclosable} bool 點(diǎn)擊蒙層是否允許關(guān)閉
* @param {maskStyle} object 遮罩樣式
* @param {okText} string|ReactNode 確認(rèn)按鈕的文本
* @param {title} string|ReactNode 標(biāo)題內(nèi)容
* @param {visible} bool Modal是否可見
* @param {width} string Modal寬度
* @param {onCancel} func 點(diǎn)擊遮罩或者取消按鈕,或者鍵盤esc按鍵時(shí)的回調(diào)
* @param {onOk} func 點(diǎn)擊確定的回調(diào)
*/
function Modal(props) {
const {
afterClose,
bodyStyle,
cancelText,
centered,
closable,
closeIcon,
destroyOnClose,
footer,
keyboard,
mask,
maskclosable,
maskStyle,
okText,
title,
visible,
width,
onCancel,
onOk
} = props
return <div className="xModalWrap">
<div className="xModalContent">
<div className="xModalHeader">
</div>
<div className="xModalBody">
</div>
<div className="xModalFooter">
</div>
</div>
<div className="xModalMask"></div>
</div>
}
export default Modal
有了這個(gè)框架,我們來一步步往里面實(shí)現(xiàn)內(nèi)容吧。
(2)實(shí)現(xiàn)基礎(chǔ)配置功能
基礎(chǔ)配置功能往往和業(yè)務(wù)邏輯無關(guān), 僅僅用來控制元素的顯示隱藏等,由于其非常容易實(shí)現(xiàn),所以我們先來實(shí)現(xiàn)以下這些屬性的功能:
- bodyStyle
- cancelText
- closable
- closeIcon
- footer
- mask
- maskStyle
- okText
- title
- width
這幾個(gè)功能在框架搭建好之后已經(jīng)部分實(shí)現(xiàn)了,是因?yàn)樗麄兌急容^簡單,不會(huì)牽扯到其他復(fù)雜邏輯。只需要對外暴露屬性并使用屬性即可。具體實(shí)現(xiàn)如下:
// ...
function Modal(props) {
// ...
return <div className="xModalWrap">
<div
className="xModalContent"
style={{
width
}}
>
<div className="xModalHeader">
<div className="xModalTitle">
{ title }
</div>
</div>
{
closable &&
<span className="xModalCloseBtn">
{ closeIcon || <Icon type="FaTimes" /> }
</span>
}
<div className="xModalBody" style={bodyStyle}>
{ children }
</div>
{
footer === null ? null :
<div className="xModalFooter">
{
footer ? footer :
<div className="xFooterBtn">
<Button className="xFooterBtnCancel" type="pure">{ cancelText }</Button>
<Button className="xFooterBtnOk">{ okText }</Button>
</div>
}
</div>
}
</div>
{
mask && <div className="xModalMask" style={maskStyle}></div>
}
</div>
}
通過以上實(shí)現(xiàn),我們很容易控制一個(gè)modal組件具體顯示那些元素,以及那些元素是可關(guān)閉modal的,具體案例如下:
- 去除footer(通過設(shè)置footer為null)。
- 去除右上角的關(guān)閉按鈕。
- 去除mask遮罩。
(3)實(shí)現(xiàn)visible(帶有彈窗出來和隱藏的動(dòng)畫animation)
熟悉antd或者element的朋友都知道,visible用來控制modal的顯示和隱藏,我們這里也來實(shí)現(xiàn)同樣的功能,關(guān)于隱藏和顯示的動(dòng)畫,我們這里用transform:scale來實(shí)現(xiàn)。先來看看實(shí)現(xiàn)效果吧:
這里筆者使用了react hooks的useState這個(gè)API,來設(shè)置彈窗可見性的state,modal默認(rèn)不可見。具體邏輯如下:
let [isHidden, setHidden] = useState(!props.visible)
const handleClose = () => {
setHidden(false)
}
html結(jié)構(gòu)如下:
<div className="xModalWrap" style={{display: isHidden ? 'none' : 'block'}}>
由以上代碼我們知道模態(tài)框的顯示隱藏是通過設(shè)置display:none/block來控制的,但是我們都知道display:none是不能執(zhí)行動(dòng)畫效果的,為了實(shí)現(xiàn)內(nèi)容彈窗的動(dòng)畫,我們這里采用了@keyframe動(dòng)畫,對于低版本瀏覽器也采用了很好的向下兼容。具體css代碼如下:
@keyframes xSpread {
0% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
(4)實(shí)現(xiàn)centered
centered屬性的作用就是來控制彈窗內(nèi)容距離整個(gè)遮罩或者可視區(qū)域的位置的,值為true則居與遮罩或者可視區(qū)域的正中心。因?yàn)槲覀兡J(rèn)設(shè)置的modal內(nèi)容區(qū)域的位置是左右居中,頂部距離可視區(qū)域頂部100px,所以這里我們實(shí)現(xiàn)如下:
<div className={`xModalContent${centered ? ' xCentered' : ''}`}>
&.xCentered {
top: 50%;
transform: translateY(-50%);
}
這個(gè)實(shí)現(xiàn)也非常簡單,就是通過屬性centered來動(dòng)態(tài)的設(shè)置類名即可。
(5)實(shí)現(xiàn)destroyOnClose
這個(gè)功能意思是在彈窗關(guān)閉時(shí)是否清除子元素,我在:《精通react/vue組件設(shè)計(jì)》之配合React Portals實(shí)現(xiàn)一個(gè)功能強(qiáng)大的抽屜(Drawer)組件這篇文章中有詳細(xì)的介紹,大家感興趣可以研究以下,這里我指介紹實(shí)現(xiàn)過程。
當(dāng)destroyOnClose為true時(shí),我們銷毀子元素即可,通過維護(hù)一個(gè)state來實(shí)現(xiàn)組件的重新渲染。要想實(shí)現(xiàn)該功能,我們需要處理如下幾個(gè)事件:
- 當(dāng)點(diǎn)擊關(guān)閉按鈕時(shí),根據(jù)destroyOnClose銷毀子組件。
- 當(dāng)點(diǎn)擊確認(rèn)按鈕時(shí),根據(jù)destroyOnClose銷毀子組件。
- 當(dāng)visible為true,根據(jù)destroyOnClose將子組件重新渲染出來。
具體實(shí)現(xiàn)代碼如下:
// 關(guān)閉事件(關(guān)閉和確認(rèn)事件邏輯基本一致,這里就不單獨(dú)寫了)
const handleClose = () => {
setHidden(true)
if(destroyOnClose) {
setDestroyChild(true)
}
document.body.style.overflow = 'auto'
onCancel && onCancel()
}
// visivle/destroyOnClose更新時(shí),重新渲染子組件
useEffect(() => {
if(visible) {
if(destroyOnClose) {
setDestroyChild(true)
}
}
}, [visible, destroyOnClose])
這樣我們就實(shí)現(xiàn)了彈窗關(guān)閉時(shí)銷毀組件的功能。
(6)實(shí)現(xiàn)鍵盤按鍵ESC時(shí)關(guān)閉模態(tài)框(Modal)
為了更好的用戶體檢,筆者的Modal組件支持鍵盤事件,我們都知道鍵盤的ESC對應(yīng)的事件碼為27,那么我們就能根據(jù)這個(gè)原理來實(shí)現(xiàn)鍵盤按鍵ESC時(shí)關(guān)閉模態(tài)框:
useEffect(() => {
document.onkeydown = function (event) {
let e = event || window.event || arguments.callee.caller.arguments[0]
if (e && e.keyCode === 27) {
handleClose()
}
}
}, [])
因?yàn)槭录O(jiān)聽只需要執(zhí)行一次,所以useEffect的依賴設(shè)置為空數(shù)組即可。雖然這樣已經(jīng)基本實(shí)現(xiàn)了鍵盤關(guān)閉的功能,但是這樣的代碼明顯不夠優(yōu)雅,所以我們來完善以下,我們可以將鍵盤關(guān)閉的方法抽離出來,然后在useEffect的第一個(gè)回調(diào)函數(shù)中返回另一個(gè)函數(shù)(該函數(shù)里是組件卸載前的鉤子),當(dāng)組件卸載時(shí)我們將事件監(jiān)聽移除,這樣可以提高一些性能,對內(nèi)存優(yōu)化也有幫助:
const closeModal = function (event) {
let e = event || window.event || arguments.callee.caller.arguments[0]
if (e && e.keyCode === 27) {
handleClose()
}
}
useEffect(() => {
document.addEventListener('keydown', closeModal, false)
return () => {
document.removeEventListener('keydown', closeModal, false)
}
}, [])
通過這種方式,代碼和功能實(shí)現(xiàn)上是不是會(huì)更優(yōu)雅呢?
(7)實(shí)現(xiàn)afterClose
afterClose的作用主要是在模態(tài)框關(guān)閉之后執(zhí)行某個(gè)回調(diào)函數(shù)。我們使用class組件很好實(shí)現(xiàn)這個(gè)功能,因?yàn)閟etState可以傳兩個(gè)參數(shù),一個(gè)是更新state的回調(diào),另一個(gè)是state更新之后的回調(diào),我們只需要把a(bǔ)fterClose放到更新后的回調(diào)即可,也就是第二個(gè)參數(shù)回調(diào)里。但是我們modal組件目前是用react hooks和函數(shù)式組件寫的,那么怎么實(shí)現(xiàn)狀態(tài)更新后的回調(diào)呢?筆者這里提供一個(gè)實(shí)現(xiàn)思路,利用閉包來實(shí)現(xiàn),核心代碼如下:
// 函數(shù)組件外部
let hiddenCount = 0;
// 函數(shù)組件內(nèi)部
useEffect(() => {
if(isHidden && hiddenCount) {
hiddenCount = 0
afterClose && afterClose()
}
hiddenCount = 1
}, [isHidden])
我們知道useEffect不僅僅可以實(shí)現(xiàn)監(jiān)聽掛載組件的鉤子,也同樣能監(jiān)聽state更新,我們利用這一點(diǎn)來實(shí)現(xiàn)該功能,值得注意的是我們要在執(zhí)行afterClose前重置hiddenCount,避免其他使用modal組件的函數(shù)的影響。
(8)健壯性支持, 我們采用react提供的propTypes工具:
import PropTypes from 'prop-types'
// ...
Modal.propTypes = {
afterClose: PropTypes.func,
bodyStyle: PropTypes.object,
cancelText: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element
]),
centered: PropTypes.bool,
closable: PropTypes.bool,
closeIcon: PropTypes.element,
destroyOnClose: PropTypes.bool,
footer: PropTypes.oneOfType([
PropTypes.element,
PropTypes.object
]),
keyboard: PropTypes.bool,
mask: PropTypes.bool,
maskclosable: PropTypes.bool,
maskStyle: PropTypes.object,
okText: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element
]),
title: PropTypes.oneOfType([
PropTypes.string,
PropTypes.element
]),
visible: PropTypes.bool,
width: PropTypes.string,
onCancel: PropTypes.func,
onOk: PropTypes.func
}
關(guān)于prop-types的使用官網(wǎng)上有很詳細(xì)的案例,這里說一點(diǎn)就是oneOfType的用法, 它用來支持一個(gè)組件可能是多種類型中的一個(gè)。組件完整css代碼如下:
.xModalWrap {
position: fixed;
z-index: 999;
top: 0;
left: 0;
width: 100%;
bottom: 0;
overflow: hidden;
.xModalContent {
position: relative;
z-index: 1000;
margin-left: auto;
margin-right: auto;
position: relative;
top: 100px;
background-color: #fff;
background-clip: padding-box;
border-radius: 4px;
-webkit-box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
pointer-events: auto;
animation: xSpread .3s;
&.xCentered {
top: 50%;
transform: translateY(-50%);
}
.xModalHeader {
padding: 16px 24px;
color: rgba(0, 0, 0, 0.65);
background: #fff;
border-bottom: 1px solid #e8e8e8;
border-radius: 4px 4px 0 0;
.xModalTitle {
margin: 0;
color: rgba(0, 0, 0, 0.85);
font-weight: 500;
font-size: 16px;
line-height: 22px;
word-wrap: break-word;
}
}
.xModalCloseBtn {
position: absolute;
top: 0;
right: 0;
z-index: 10;
padding: 0;
width: 56px;
height: 56px;
color: rgba(0, 0, 0, 0.45);
font-size: 16px;
line-height: 56px;
text-align: center;
text-decoration: none;
background: transparent;
border: 0;
outline: 0;
cursor: pointer;
}
.xModalBody {
padding: 16px 24px;
}
.xModalFooter {
padding: 10px 16px;
text-align: right;
background: transparent;
border-top: 1px solid #e8e8e8;
border-radius: 0 0 4px 4px;
.xFooterBtn {
.xFooterBtnCancel, .xFooterBtnOk {
margin-left: 6px;
margin-right: 6px;
}
}
}
}
.xModalMask {
position: fixed;
z-index: 999;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
background-color: rgba(0,0,0, .5);
}
}
@keyframes xSpread {
0% {
opacity: 0;
// 之所以要再加translateY(-50%),是為了防止動(dòng)畫抖動(dòng)
transform: translateY(-50%) scale(0);
}
100% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
通過以上步驟, 一個(gè)健壯的的Modal組件就完成了.Modal組件算是組件庫中中等復(fù)雜的組件,如果不懂的可以在評論區(qū)提問,筆者看到后會(huì)第一時(shí)間解答。
(9)使用Modal組件
我們可以通過如下方式使用它:
<Modal title="xui基礎(chǔ)彈窗" centered mask={false} visible={false}>
<p>我是彈窗內(nèi)容</p>
<p>我是彈窗內(nèi)容</p>
<p>我是彈窗內(nèi)容</p>
<p>我是彈窗內(nèi)容</p>
</Modal>
筆者已經(jīng)將實(shí)現(xiàn)過的組件發(fā)布到npm上了,大家如果感興趣可以直接用npm安裝后使用,方式如下:
npm i @alex_xu/xui
// 導(dǎo)入xui
import {
Button,
Skeleton,
Empty,
Progress,
Tag,
Switch,
Drawer,
Badge,
Alert
} from '@alex_xu/xui'
該組件庫支持按需導(dǎo)入,我們只需要在項(xiàng)目里配置babel-plugin-import即可,具體配置如下:
// .babelrc
"plugins": [
["import", { "libraryName": "@alex_xu/xui", "style": true }]
]
npm庫截圖如下:
最后
后續(xù)筆者將會(huì)繼續(xù)實(shí)現(xiàn)
- badge(徽標(biāo))
- table(表格)
- tooltip(工具提示條)
- Skeleton(骨架屏)
- Message(全局提示)
- form(form表單)
- switch(開關(guān))
- 日期/日歷
- 二維碼識別器組件
等組件, 來復(fù)盤筆者多年的組件化之旅。