《精通react/vue組件設計》之配合React Portals實現一個功能強大的抽屜組件

前言

本文是筆者寫組件設計的第六篇文章,內容依次從易到難,今天會用到react的高級API React Portals,它也是不少複雜組件必用的方法之一. 經過組件的設計過程,你們會接觸到一個完成健壯的組件設計思路和方法,也能在實現組件的過程逐漸對react/vue的高級知識和技巧有更深的理解和掌握,而且在企業實際工做作遊刃有餘.javascript

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

做爲數據驅動的領導者react/vue等MVVM框架的出現,幫咱們減小了工做中大量的冗餘代碼, 一切皆組件的思想深得人心. 爲了讓工程師們有更多的時間去考慮業務和產品迭代,咱們不得不掌握高質量組件設計的思路和方法.因此筆者將花時間去總結各類業務場景下的組件的設計思路和方法,並用原生框架的語法去實現各類經常使用組件的開發,但願能讓前端新手或者有必定工做經驗的朋友能有所收穫.前端

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

正文

在開始組件設計以前但願你們對css3和js有必定的基礎,並瞭解基本的react/vue語法.咱們先看看實現後的組件效果:css3

1. 組件設計思路

按照以前筆者總結的組件設計原則,咱們第一步是要確認需求. 一個抽屜(Drawer)組件會有以下需求點:程序員

  • 能控制抽屜是否可見

  • 能手動配置抽屜的關閉按鈕

  • 能控制抽屜的打開方向

  • 關閉抽屜時是否銷燬裏面的子元素(這個問題是工做中頻繁遇到的問題)

  • 指定 Drawer 掛載的 HTML 節點, 能夠將抽屜掛載在任何元素上

  • 點擊蒙層能夠控制是否容許關閉抽屜

  • 能控制遮罩層的展現

  • 能自定義抽屜彈出層樣式

  • 能夠設置抽屜彈出層寬度

  • 能控制彈出層層級

  • 能控制抽屜彈出方向(上下左右)

  • 點擊關閉按鈕時能提供回調供開發者進行相關操做

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



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

經過以上需求分析, 是否是以爲一個抽屜組件要實現這麼多功能很複雜呢? 確實有點複雜,可是不要怕,有了上面精確的需求分析,咱們只須要一步步按照功能點實現就行了.對於咱們經常使用的table組件, modal組件等其實也須要考慮到不少使用場景和功能點, 好比antd的table組件暴露了幾十個屬性,若是很差好理清具體的需求, 實現這樣的組件是很是麻煩的.接下來咱們就來看看具體實現.

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

2.1. Drawer組件框架設計

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

import PropTypes from 'prop-types'
import styles from './index.less'

/**
 * Drawer 抽屜組件
 * @param {visible} bool 抽屜是否可見
 * @param {closable} bool 是否顯示右上角的關閉按鈕
 * @param {destroyOnClose} bool 關閉時銷燬裏面的子元素
 * @param {getContainer} HTMLElement 指定 Drawer 掛載的 HTML 節點, false 爲掛載在當前 dom
 * @param {maskClosable} bool 點擊蒙層是否容許關閉抽屜
 * @param {mask} bool 是否展現遮罩
 * @param {drawerStyle} object 用來設置抽屜彈出層樣式
 * @param {width} number|string 彈出層寬度
 * @param {zIndex} number 彈出層層級
 * @param {placement} string 抽屜方向
 * @param {onClose} string 點擊關閉時的回調
 */

function Drawer(props{
  const { 
    closable = true
    destroyOnClose, 
    getContainer = document.body, 
    maskClosable = true
    mask = true
    drawerStyle, 
    width = '300px',
    zIndex = 10,
    placement = 'right'
    onClose,
    children
  } = props

  const childDom = (
    <div className={styles.xDrawerWrap}>
      <div className={styles.xDrawerMask} ></div>
      <div 
        className={styles.xDrawerContent} 
        {
          children
        }
        {
          !!closable && <span className={styles.xCloseBtn}>
X</span>
        }
      </div>
    </div>

  )
  return childDom
}

export default Drawer

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

2.2 實現visible, closable, onClose, mask, maskClosable, width, zIndex, drawerStyle

之因此要先實現這幾個功能,是由於他們實現都比較簡單,不會牽扯到其餘複雜邏輯.只須要對外暴露屬性並使用屬性便可. 具體實現以下:

function Drawer(props{
  const { 
    closable = true
    destroyOnClose, 
    getContainer = document.body, 
    maskClosable = true
    mask = true
    drawerStyle, 
    width = '300px',
    zIndex = 10,
    placement = 'right'
    onClose,
    children
  } = props

  let [visible, setVisible] = useState(props.visible)

  const handleClose = () => {
    setVisible(false)
    onClose && onClose()
  }

  useEffect(() => {
    setVisible(props.visible)
  }, [props.visible])

  const childDom = (
    <div 
      className={styles.xDrawerWrap} 
      style={{
        width: visible ? '100%' : '0',
        zIndex
      }}
    >

      { !!mask && <div className={styles.xDrawerMask} onClick={maskClosable ? handleClose : null}></div> }
      <div 
        className={styles.xDrawerContent} 
        style={{
          width,
          ...drawerStyle
        }}>

        { children }
        {
          !!closable && <span className={styles.xCloseBtn} onClick={handleClose}>X</span>
        }
      </div>
    </div>

  )
  return childDom
}

上述實現過程值得注意的就是咱們組件設計採用了react hooks技術, 在這裏用到了useState, useEffect, 若是你們不懂的能夠去官網學習, 很是簡單,若是有不懂的能夠和筆者交流或者在評論區提問. 抽屜動畫咱們經過控制抽屜內容的寬度來實現,配合overflow:hidden, 後面我會單獨附上css代碼供你們參考.

2.3 實現destroyOnClose

destroyOnClose主要是用來清除組件緩存,比較經常使用的場景就是輸入文本,好比當我是的抽屜的內容是一個表單建立頁面時,咱們關閉抽屜但願表單中用戶輸入的內容清空,保證下次進入時用戶能從新建立, 可是實際狀況是若是咱們不銷燬抽屜裏的子組件, 子組件內容不會清空,用戶下次打開時開始以前的輸入,這明顯不合理. 以下圖所示:

要想清除緩存,首先就要要內部組件從新渲染,因此咱們能夠經過一個state來控制,若是用戶明確指定了關閉時要銷燬組件,那麼咱們就更新這個state,從而這個子元素也就不會有緩存了.具體實現以下:
function Drawer(props{
  // ...
  let [isDesChild, setIsDesChild] = useState(false)

  const handleClose = () => {
    // ...
    if(destroyOnClose) {
      setIsDesChild(true)
    }
  }

  useEffect(() => {
    // ...
    setIsDesChild(false)
  }, [props.visible])

  const childDom = (
    <div className={styles.xDrawerWrap}>
      <div className={styles.xDrawerContent} 
        {
          isDesChild ? null : children
        }
      </div>

    </div>

  )
  return childDom
}

上述代碼中咱們省略了部分不相關代碼, 主要來關注isDesChild和setIsDesChild, 這個屬性用來根據用戶傳入的destroyOnClose屬性倆判斷是否該更新這個state, 若是destroyOnClose爲true,說明要更新,那麼此時當用戶點擊關閉按鈕的時候, 組件將從新渲染, 在用戶再次點開抽屜時, 咱們根據props.visible的變化,來從新讓子組件渲染出來,這樣就實現了組件卸載的完整流程.

2.4 實現getContainer

getContainer主要用來控制抽屜組件的渲染位置,默認會渲染到body下, 爲了提供更靈活的配置,咱們須要讓抽屜能夠渲染到任何元素下,這樣又怎麼實現呢? 這塊實現咱們能夠採用React Portals來實現,具體api介紹以下:

Portal 提供了一種將子節點渲染到存在於父組件之外的 DOM 節點的優秀的方案。第一個參數(child)是任何可渲染的 React 子元素,例如一個元素,字符串或 fragment。第二個參數(container)是一個 DOM 元素。

具體使用以下:
render() {
  // `domNode` 是一個能夠在任何位置的有效 DOM 節點。
  return ReactDOM.createPortal(
    this.props.children,
    domNode
  );
}
因此基於這個api咱們就能把抽屜渲染到任何元素下了, 具體實現以下:
const childDom = (
    <div 
      className={styles.xDrawerWrap} 
      style={{
        position: getContainer === false ? 'absolute: 'fixed',
        width: visible ? '100%' : '0',
        zIndex
      }}
    >

      { !!mask && <div className={styles.xDrawerMask} onClick={maskClosable ? handleClose : null}></div> }
      <div 
        className={styles.xDrawerContent} 
        style={{
          width,
          [placement]: visible ? 0 : '-100%',
          ...drawerStyle
        }}>

        {
          isDesChild ? null : children
        }
        {
          !!closable && <span className={styles.xCloseBtn} onClick={handleClose}>X</span>
        }
      </div>
    </div>

  )

  return getContainer === false ? childDom 
            : ReactDOM.createPortal(childDom, getContainer)

由於這裏getContainer要支持3種狀況,一種是用戶不配置屬性,那麼默認就掛載到body下,還有就是用戶傳的值爲false, 那麼就爲最近的父元素, 他若是傳一個dom元素,那麼將掛載到該元素下,因此以上代碼咱們會分狀況考慮,還有一點要注意,當抽屜打開時,咱們要讓父元素溢出隱藏,不讓其滾動,因此咱們在這裏要設置一下:

useEffect(() => {
    setVisible(() => {
      if(getContainer !== false && props.visible) {
        getContainer.style.overflow = 'hidden'
      }
      return props.visible
    })
    setIsDesChild(false)
  }, [props.visible, getContainer])
當關閉時恢復邏輯父級的overflow, 避免影響外部樣式:
const handleClose = () => {
    onClose && onClose()
    setVisible((prev) => {
      if(getContainer !== false && prev) {
        getContainer.style.overflow = 'auto'
      }
      return false
    })
    if(destroyOnClose) {
      setIsDesChild(true)
    }
  }

2.5 實現placement

placement主要用來控制抽屜的彈出方向, 能夠從左彈出,也能夠從右彈出, 實現過程也比較簡單,咱們主要要更具屬性動態修改定位屬性便可,這裏咱們會用到es新版的新特性,對象的變量屬性. 核心代碼以下:

<div 
  className={styles.xDrawerContent} 
  style={{
    width,
    [placement]: visible ? 0 : '-100%',
    ...drawerStyle
    }}>

 </div>

這樣,不管是上下左右,均可以完美實現了.

2.6 健壯性支持, 咱們採用react提供的propTypes工具:

import PropTypes from 'prop-types'
// ...
Drawer.propTypes = {
  visible: PropTypes.bool,
  closable: PropTypes.bool, 
  destroyOnClose: PropTypes.bool, 
  getContainer: PropTypes.element, 
  maskClosable: PropTypes.bool, 
  mask: PropTypes.bool, 
  drawerStyle: PropTypes.object, 
  width: PropTypes.oneOfType([
    PropTypes.string,
    PropTypes.number
  ]),
  zIndex: PropTypes.number,
  placement: PropTypes.string, 
  onClose: PropTypes.func
}

關於prop-types的使用官網上有很詳細的案例,這裏說一點就是oneOfType的用法, 它用來支持一個組件多是多種類型中的一個. 組件相關css代碼以下:

.xDrawerWrap {
top: 0;
height: 100vh;
overflow: hidden;
.xDrawerMask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, .5);
}
.xDrawerContent {
position: absolute;
top: 0;
padding: 16px;
height: 100%;
transition: all .3s;
background-color: #fff;
box-shadow: 0 0 20px rgba(0,0,0, .2);
.xCloseBtn {
position: absolute;
top: 10px;
right: 10px;
color: #ccc;
cursor: pointer;
}
}
}

經過以上步驟, 一個功能強大的的drawer組件就完成了,關於代碼中的css module和classnames的使用你們能夠本身去官網學習,很是簡單.若是不懂的能夠在評論區提問,筆者看到後會第一時間解答.

擴展

目前筆者已經將完成的組件庫發佈到npm上了,你們能夠經過npm安裝包的方式使用:

npm i @alex_xu/xui

// 使用
import { Button, Alert } from '@alex_xu/xui'

在線文檔地址: xui——基於react的輕量級UI組件庫

npm包地址: @alex_xu/xui

最後

後續筆者已經實現

  • modal(模態窗),

  • alert(警告提示),

  • badge(徽標),

  • table(表格),

  • tooltip(工具提示條),

  • Skeleton(骨架屏),

  • Message(全局提示),

  • form(form表單),

  • switch(開關),

  • 日期/日曆,

  • 二維碼識別器組件

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

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

❤️愛心三連擊

1.看到這裏了就點個在看支持下吧,你的「點贊,在看」是我創做的動力。

2.關注公衆號趣談前端,進程序員優質學習交流羣, 字節, 阿里大佬和你一塊兒學習成長!

3.也可添加微信【Mr_xuxiaoxi】獲取大廠內推機會。

本文分享自微信公衆號 - 趣談前端(beautifulFront)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索