前言
本文是筆者寫組件設計的第六篇文章,內容依次從易到難,今天會用到react的高級API React Portals,它也是很多復雜組件必用的方法之一. 通過組件的設計過程,大家會接觸到一個完成健壯的組件設計思路和方法,也能在實現組件的過程逐漸對react/vue的高級知識和技巧有更深的理解和掌握,并且在企業實際工作做游刃有余.
- 之所以會寫組件設計相關的文章,是因為作為一名前端優秀的前端工程師,面對各種繁瑣而重復的工作,我們不應該按部就班的去"辛勤勞動",而是要根據已有前端的開發經驗,總結出一套自己的高效開發的方法.
作為數據驅動的領導者react/vue等MVVM框架的出現,幫我們減少了工作中大量的冗余代碼, 一切皆組件的思想深得人心. 為了讓工程師們有更多的時間去考慮業務和產品迭代,我們不得不掌握高質量組件設計的思路和方法.所以筆者將花時間去總結各種業務場景下的組件的設計思路和方法,并用原生框架的語法去實現各種常用組件的開發,希望能讓前端新手或者有一定工作經驗的朋友能有所收獲.
如果對于react/vue組件設計原理不熟悉的,可以參考我的之前寫的組件設計系列文章:
- 《精通react/vue組件設計》之5分鐘實現一個Tag(標簽)組件和Empty(空狀態)組件
- 《精通react/vue組件設計》之用純css打造類materialUI的按鈕點擊動畫并封裝成react組件
- 《精通react/vue組件設計》之快速實現一個可定制的進度條組件
- 《精通react/vue組件設計》之基于jsoneditor二次封裝一個可實時預覽的json編輯器組件(react版)
正文
在開始組件設計之前希望大家對css3和js有一定的基礎,并了解基本的react/vue語法.我們先看看實現后的組件效果:

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(開關),
- 日期/日歷,
- 二維碼識別器組件
等組件, 來復盤筆者多年的組件化之旅.
原文地址:https://mp.weixin.qq.com/s/gbWUTtRTZGQI29wQkx07fA