手摸手實現一個輕量級可擴展的模態框(Modal)組件

前言

本文是筆者寫組件設計的第九篇文章, 今天帶你們實現一個輕量級且可靈活配置組合的模態框(Modal)組件, 該組件在諸如Antd或者elementUI等第三方組件庫中都會出現,主要用來提供系統的用戶反饋.javascript

之因此會寫組件設計相關的文章,是由於做爲一名前端優秀的前端工程師,面對各類繁瑣而重複的工做,咱們不該該循序漸進的去"辛勤勞動",而是要根據已有前端的開發經驗,總結出一套本身的高效開發的方法.css

[筆記]前端組件的通常分類:

  • 通用型組件: 好比Button, Icon等.
  • 佈局型組件: 好比Grid, Layout佈局等.
  • 導航型組件: 好比麪包屑Breadcrumb, 下拉菜單Dropdown, 菜單Menu等.
  • 數據錄入型組件: 好比form表單, Switch開關, Upload文件上傳等.
  • 數據展現型組件: 好比Avator頭像, Table表格, List列表等.
  • 反饋型組件: 好比Progress進度條, Drawer抽屜, Modal對話框等.
  • 其餘業務類型

因此咱們在設計組件系統的時候能夠參考如上分類去設計,該分類也是antd, element, zend等主流UI庫的分類方式.html

正文

在開始組件設計以前但願你們對css3和js有必定的基礎,並瞭解基本的react/vue語法.咱們先來解構一下Modal組件, 一個Modal分爲如下幾個部分: 前端

每個區塊均可以自定義配置, 也能夠組合其餘組件. 實現後的組件效果:

1. 組件設計思路

按照以前筆者總結的組件設計原則,咱們第一步是要確認需求. 模態框(Modal)組件通常會有以下需求點:vue

  • 能控制Modal主體的樣式
  • 提供Modal徹底關閉後的回調
  • 能控制取消按鈕文字和樣式
  • 能控制確認按鈕文字和樣式
  • 控制modal展現的位置
  • 控制是否顯示右上角的關閉按鈕
  • 能夠配置自定義關閉圖標
  • 配置關閉時是否銷燬Modal裏的子元素
  • 自定義模態框底部內容
  • 控制是否支持鍵盤esc關閉
  • 控制是否展現遮罩
  • 控制點擊蒙層是否容許關閉
  • 自定義遮罩樣式
  • 自定義標題
  • 控制對話框是否可見
  • 自定義對話框寬度
  • 暴露點擊遮罩層或右上角叉或取消按鈕的回調
  • 提供點擊肯定回調

需求收集好以後,做爲一個有追求的程序員, 會得出以下線框圖: java

對於react選手來講,若是沒用typescript,建議你們都用PropTypes, 它是react內置的類型檢測工具,咱們能夠直接在項目中導入. vue有自帶的屬性檢測方式,這裏就不一一介紹了.

2. 基於react實現一個Modal組件

2.1. Modal組件框架設計

首先咱們先根據需求將組件框架寫好,這樣後面寫業務邏輯會更清晰:node

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
複製代碼

有了這個框架,咱們來一步步往裏面實現內容吧.react

2.2 實現基礎配置功能

基礎配置功能每每和業務邏輯無關, 僅僅用來控制元素的顯示隱藏等,因爲其很是容易實現,因此咱們先來實現如下這些屬性的功能:webpack

  • bodyStyle
  • cancelText
  • closable
  • closeIcon
  • footer
  • mask
  • maskStyle
  • okText
  • title
  • width

這幾個功能在框架搭建好以後已經部分實現了,是由於他們都比較簡單,不會牽扯到其餘複雜邏輯.只須要對外暴露屬性並使用屬性便可. 具體實現以下:css3

// ...
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的,具體案例以下:

  1. 去除footer(經過設置footer爲null)
  2. 去除右上角的關閉按鈕
  3. 去除mask遮罩

2.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);
    }
}
複製代碼

2.5 實現centered

centered屬性的做用就是來控制彈窗內容距離整個遮罩或者可視區域的位置的,值爲true則居與遮罩或者可視區域的正中心。由於咱們默認設置的modal內容區域的位置是左右居中,頂部距離可視區域頂部100px,因此這裏咱們實現以下:

<div className={`xModalContent${centered ? ' xCentered' : ''}`}>
複製代碼

css代碼以下:

&.xCentered {
    top: 50%;
    transform: translateY(-50%);
}
複製代碼

這個實現也很是簡單,就是經過屬性centered來動態的設置類名便可。

2.6 實現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])
複製代碼

這樣咱們就實現了彈窗關閉時銷燬組件的功能。

2.7 實現鍵盤按鍵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)
    }
  }, [])
複製代碼

經過這種方式,代碼和功能實現上是否是會更優雅呢?

2.8 實現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組件的函數的影響。

2.9 健壯性支持, 咱們採用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組件算是組件庫中中等複雜的組件,若是不懂的能夠在評論區提問,筆者看到後會第一時間解答.

2.5 使用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(開關),
  • 日期/日曆,
  • 二維碼識別器組件

等組件, 來複盤筆者多年的組件化之旅.

若是對於react/vue組件設計原理不熟悉的,能夠參考個人以前寫的組件設計系列文章:

筆者已經將組件庫發佈到npm上了, 你們能夠經過npm安裝的方式體驗組件.

若是想獲取組件設計系列完整源碼, 或者想學習更多H5遊戲, webpacknodegulpcss3javascriptnodeJScanvas數據可視化等前端知識和實戰,歡迎在公號《趣談前端》加入咱們的技術羣一塊兒學習討論,共同探索前端的邊界。

更多推薦

相關文章
相關標籤/搜索