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