React造輪系列:對話框組件 - Dialog 思路

本文是React造輪系列第二篇。css

本輪子是經過 React + TypeScript + Webpack 搭建的,至於環境的搭建這邊就不在細說了,本身動手谷歌吧。固然能夠參考個人源碼html

想閱讀更多優質文章請猛戳GitHub博客,一年百來篇優質文章等着你!前端

UI

clipboard.png

對話框通常是咱們點擊按鈕彈出的這麼一個東西,主要類型有 Alter, ConfirmModal, Modal 通常帶有半透明的黑色背景。固然外觀可參考 AntD 或者 Framework 等。vue

肯定 API

API 方面主要仍是要參考同行,由於若是有一天,別人想你用的UI框架時,你的 API 跟他以前經常使用的又不用,這樣就加大了入門門檻,因此API 儘可能保持跟現有的差很少。react

對話框除了提供顯示屬性外,還要有點擊確認後的回放函數,如:git

alert('你好').then(fn)
confirm('肯定?').then(fn)
modal(組件名)

實現

Dialog 源碼已經上傳到這裏github

dialog/dialog.example.tsx, 這裏 state ,生命週期使用 React 16.8 新出的 Hook,若是對 Hook 不熟悉能夠先看官網文檔segmentfault

dialog/dialog.example.tsxapi

import React, {useState} from 'react'
import Dialog from './dialog'
export default function () {
  const [x, setX] = useState(false)
  return (
    <div>
      <button onClick={() => {setX(!x)}}>點擊</button>
      <Dialog visible={x}></Dialog>
    </div>
  )
}

dialog/dialog.tsx數組

import React from 'react'

interface Props {
  visible: boolean
}

const Dialog: React.FunctionComponent<Props> = (props) => {
  return (
    props.visible ? 
      <div>dialog</div> : 
      null
  )
}

export default Dialog

運行效果

圖片描述

顯示內容

上述還有問題,咱們 dialog 在組件內是寫死的,咱們想的是直接經過組件內包裹的內容,如:

// dialog/dialog.example.tsx
...
<Dialog visible={x}>
  <strong>hi</strong>
</Dialog>
...

這樣寫,頁面上是不會顯示 hi 的,這裏 children 屬性就派上用場了,咱們須要在 dialog 組件中進一步驟修改以下內容:

// dialog/dialog.tsx
...
return (
    props.visible ? 
      <div>
        {props.children}
      </div>
      : 
      null
)
...

顯示遮罩

一般對話框會有一層遮罩,一般咱們大都會這樣寫:

// dialog/dialog.tsx
...
props.visible ? 
  <div className="fui-dialog-mask">
    <div className="fui-dialog">
    {props.children}
    </div>
  </div>
  : 
  null
...

這種結構有個很差的地方就是點擊遮罩層的時候要關閉對話框,若是是用這種結構,用戶點擊任何 div,都至關於點擊遮罩層,因此最好要分開:

// dialog/dialog.tsx
...
<div>
    <div className="fui-dialog-mask">
    </div>
    <div className="fui-dialog">
      {props.children}
    </div>
 </div>
...

因爲 React 要求最外層只能有一個元素, 因此咱們多用了一個 div 包裹起來,可是這種方法無形之中多了個 div,因此可使用 React 16 以後新出的 Fragment, Fragment 跟 vue 中的 template 同樣,它是不會渲染到頁面的。

import React, {Fragment} from 'react'
import './dialog.scss';
interface Props {
  visible: boolean
}

const Dialog: React.FunctionComponent<Props> = (props) => {
  return (
    props.visible ? 
     <Fragment>
        <div className="fui-dialog-mask">
        </div>
        <div className="fui-dialog">
          {props.children}
        </div>
     </Fragment>
      : 
      null
  )
}

export default Dialog

完善頭部,內容及底部

這裏很少說,直接上代碼

import React, {Fragment} from 'react'
import './dialog.scss';
import {Icon} from '../index'
interface Props {
  visible: boolean
}

const Dialog: React.FunctionComponent<Props> = (props) => {
  return (
    props.visible ? 
      <Fragment>
          <div className="fui-dialog-mask">
          </div>
          <div className="fui-dialog">
            <div className='fui-dialog-close'>
              <Icon name='close'/>
            </div>
            <header className='fui-dialog-header'>提示</header>
            <main className='fui-dialog-main'>
              {props.children}
            </main>
            <footer className='fui-dialog-footer'>
              <button>ok</button>
              <button>cancel</button>
            </footer>
          </div>
      </Fragment>
      : 
      null
  )
}

export default Dialog

從上述代碼咱們能夠發現咱們寫樣式的名字時候,爲了避免被第三使用覆蓋,咱們自定義了一個 fui-dialog前綴,在寫每一個樣式名稱時,都要寫一遍,這樣顯然不太合理,萬一哪天我不用這個前綴時候,每一個都要改一遍,因此咱們須要一個方法來封裝。

我們可能會寫這樣方法:

function scopedClass(name) {
  return `fui-dialog-${name}`
}

這樣寫不行,由於咱們 name 可能不傳,這樣就會多出一個 -,因此須要進一步的判斷:

function scopedClass(name) {

return `fui-dialog-${name ? '-' + name : ''}`
}

那還有沒有更簡潔的方法,使用 filter 方法:

function scopedClass(name ?: string) {
  return ['fui-dialog', name].filter(Boolean).join('-')
}

調用方式以下:

....
  <Fragment>
      <div className={scopedClass('mask')}>
      </div>
      <div className={scopedClass()}>
        <div className={scopedClass('close')}>
          <Icon name='close'/>
        </div>
        <header className={scopedClass('header')}>提示</header>
        <main className={scopedClass('main')}>
          {props.children}
        </main>
        <footer className={scopedClass('footer')}>
          <button>ok</button>
          <button>cancel</button>
        </footer>
      </div>
  </Fragment>
 ...

你們在想法,這樣寫是有問題,每一個組件都寫一個函數嗎,若是 Icon 組件,我還須要寫一個 fui-icon, 解決方法是把 前綴當一個參數,如:

function scopedClass(name ?: string) {
  return ['fui-dialog', name].filter(Boolean).join('-')
}

調用方式以下:

className={scopedClass('fui-dialog', 'mask')}

這樣寫,還不如直接寫樣式,這種方式是等於白寫了一個方法,那怎麼辦?這就須要高階函數出場了。實現以下:

function scopeClassMaker(prefix: string) {
  return function (name ?: string) {
    return [prefix, name].filter(Boolean).join('-')
  }
}

const scopedClass = scopeClassMaker('fui-dialog')

scopeClassMaker 函數是高級函數,返回一個帶了 prefix 參數的函數。

事件處理

在寫事件處理以前,咱們 Dialog 須要接收一個 buttons 屬性,就是顯示的操做按鈕並添加事件:

// dialog/dialog.example.tsx
...
<Dialog visible={x} buttons = {
  [
    <button onClick={()=> {setX(false)}}>1</button>,
    <button onClick={()=> {setX(false)}}>2</button>,
  ]
}>
  <div>hi</div>
</Dialog>
...

我們看到這個,第一反應應該是以爲這樣寫很麻煩,我寫個 dialog, visible要本身,按鈕要本身,連事件也要本身寫。請接受這種設定。雖然麻煩,但很是的好理解。這跟 Vue 的理念是不太同樣的。固然後面會進一步驟優化。

組件內渲染以下:

<footer className={sc('footer')}>
  {
    props.buttons
  }
</footer>

運行起來你會發現有個警告:

clipboard.png

主要是說咱們渲染數組時,須要加個 key,解決方法有兩種,就是不要使用數組方式,固然這不治本,因此這裏 React.cloneElemen 出場了,它能夠克隆元素並添加對應的屬性值,以下:

{
  props.buttons.map((button, index) => {
    React.cloneElement(button, {key: index})
  })
}

對應的點擊關閉事件相對容易這邊就不講了,能夠自行查看源碼

接下來來看一個樣式的問題,首先先給出咱們遮罩的樣式:

.fui-dialog {
  position: fixed; background: white; min-width: 20em;
  z-index: 2;
  border-radius: 4px; top: 50%; left: 50%; transform: translate(-50%, -50%);
  &-mask { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
    background: fade_out(black, 0.5);
    z-index: 1;
  }
  .... 如下省略其它樣式
}

咱們遮罩 .fui-dialog-mask 使用 fixed 定位感受是沒問題的,那若是在調用 dialog 同級在加如下這麼元素:

<div style={{position:'relative', zIndex: 10, background:'#fff'}}>666</div>
   
<button onClick={() => {setX(!x)}}>點擊</button>
<Dialog visible={x}>
...
</Dialog>

運行效果:

clipboard.png

發現遮罩並無遮住 666 的內容。這是爲何?

clipboard.png

看結構也很好理解,遮罩元素與 666 是同級結構,且層級比 666 低,固然是覆蓋不了的。那我們可能就會這樣作,給.fui-dialog-mask設置一個 zIndex 比它大的唄,如 9999

效果:

clipboard.png

恩,感受沒問題,這時咱們在 Dialog 組件在嵌套一層 zIndex 爲 9 的呢,如:

<div style={{position:'relative', zIndex: 9, background:'#fff'}}>
  <Dialog visible={x}>
    ...
  </Dialog>
</div>

運行效果以下:

clipboard.png

發現,父元素被壓住了,裏面元素 zIndex 值如何的高,都沒有效果。

那這要怎麼破?答案是不要讓它出如今任何元素的裏面,這怎麼可能呢。這裏就須要引出一個神奇的 API了。這個 API 叫作 傳送門(portal)

用法以下:

return ReactDOM.createPortal(
  this.props.children,
  domNode
);

第一個參數就是你的 div,第二個參數就是你要去的地方。

import React, {Fragment, ReactElement} from 'react'
import ReactDOM from 'react-dom'
import './dialog.scss';
import {Icon} from '../index'
import {scopedClassMaker} from '../classes'

interface Props {
  visible: boolean,
  buttons: Array<ReactElement>,
  onClose: React.MouseEventHandler,
  closeOnClickMask?: boolean
}

const scopedClass = scopedClassMaker('fui-dialog')
const sc = scopedClass

const Dialog: React.FunctionComponent<Props> = (props) => {

  const onClickClose: React.MouseEventHandler = (e) => {
    props.onClose(e)
  }
  const onClickMask: React.MouseEventHandler = (e) => {
    if (props.closeOnClickMask) {
      props.onClose(e)
    }
  }
  const x = props.visible ? 
  <Fragment>
      <div className={sc('mask')} onClick={onClickMask}>
      </div>
      <div className={sc()}>
        <div className={sc('close')} onClick={onClickClose}>
          <Icon name='close'/>
        </div>
        <header className={sc('header')}>提示</header>
        <main className={sc('main')}>
          {props.children}
        </main>
        <footer className={sc('footer')}>
          {
            props.buttons.map((button, index) => {
              React.cloneElement(button, {key: index})
            })
          }
        </footer>
      </div>
  </Fragment>
  : 
  null
  return (
    ReactDOM.createPortal(x, document.body)
  )
}

Dialog.defaultProps = {
  closeOnClickMask: false
}


export default Dialog

運行效果:

clipboard.png

固然這樣,若是 Dialog 層級比同級的 zIndex 小的話,仍是覆蓋不了。 那 zIndex 通常設置成多少比較合理。通常 Dialog 這層設置成 1, mask 這層設置成2。定的越小越好,由於用戶能夠去改。

zIndex 的管理

clipboard.png

zIndex 管理通常就是前端架構師要作的了,根據業務產景來劃分,如廣告確定是要在頁面最上面,因此 zIndex 通常是屬於最高級的。

便利的 API 之 Alert

上述咱們使用 Dialog 組件調用方式比較麻煩,寫了一堆,有時候咱們想到使用 alert 直接彈出一個對話框這樣簡單方便。如

<h1>example 3</h1>
  <button onClick={() => alert('1')}>alert</button>

咱們想直接點擊 button ,而後彈出咱們自定義的對話框內容爲1 ,須要在 Dialog 組件內咱們須要導出一個 alert 方法,以下:

// dialog/dialog.tsx
...
const alert = (content: string) => {
  const component = <Dialog visible={true} onClose={() => {}}>
    {content}
  </Dialog>
  const div = document.createElement('div')
  document.body.append(div)
  ReactDOM.render(component, div)
}

export {alert}
...

運行效果:

圖片描述

但有個問題,由於對話框的 visible 是由外部傳入的,且 React 是單向數據流的,在組件內並不能直接修改 visible,因此在 onClose 方法咱們須要再次渲染一個新的組件,並設置新組件 visibleture,覆蓋原來的組件:

...
const alert = (content: string) => {
  const component = <Dialog visible={true} onClose={() => {
    ReactDOM.render(React.cloneElement(component, {visible: false}), div)
    ReactDOM.unmountComponentAtNode(div)
    div.remove()
  }}>
    {content}
  </Dialog>
  const div = document.createElement('div')
  document.body.append(div)
  ReactDOM.render(component, div)
}
..

便利的 API 之 confirm

confirm 調用方式:

<button onClick={() => confirm('1', ()=>{}, ()=> {})}>confirm</button>

第一個參數是顯示的內容,每二個參數是確認的回調,第三個參數是取消的回調函數。

實現方式:

const confirm = (content: string, yes?: () => void, no?: () => void) => {
  const onYes = () => {
    ReactDOM.render(React.cloneElement(component, {visible: false}), div)
    ReactDOM.unmountComponentAtNode(div)
    div.remove()
    yes && yes()
  }
  const onNo = () => {
    ReactDOM.render(React.cloneElement(component, {visible: false}), div)
    ReactDOM.unmountComponentAtNode(div)
    div.remove()
    no && no()
  }
  const component = (
  <Dialog 
    visible={true} onClose={() => { onNo()}}
    buttons={[<button onClick={onYes}>yes</button>, 
              <button onClick={onNo}>no</button>
            ]}
  >
    {content}
  </Dialog>)
  const div = document.createElement('div')
  document.body.appendChild(div)
  ReactDOM.render(component, div)
}

事件處理跟 Alter 差很少,惟一多了一步就是 confirm 當點擊 yes 或者 no 的時候,若是外部有回調就須要調用對應的回調函數。

便利的 API 之 modal

modal 調用方式:

<button onClick={() => {modal(<h1>你好</h1>)}}>modal</button>

modal 對應傳遞的內容就不是單單的文本了,而是元素。

實現方式:

const modal = (content: ReactNode | ReactFragment) => {
  const onClose = () => {
    ReactDOM.render(React.cloneElement(component, {visible: false}), div)
    ReactDOM.unmountComponentAtNode(div)
    div.remove()
  }
  const component = <Dialog onClose={onClose} visible={true}>
    {content}
  </Dialog>
  const div = document.createElement('div')
  document.body.appendChild(div)
  ReactDOM.render(component, div)
}

注意,這邊的 content 類型。

運行效果:

圖片描述

這還有個問題,若是須要加按鈕呢,可能會這樣寫:

<button onClick={() => {modal(<h1>
     你好 <button>close</button></h1> 
  )}}>modal</button>

這樣是關不了的,由於 Dialog 是封裝在 modal 裏面的。若是要關,必須控制 visible,那很顯然我從外面控制不了裏面的 visible,因此這個 button 沒有辦法把這個 modal 關掉。

解決方法就是使用閉包,咱們能夠在 modal 方法裏面把 close 方法返回:

const modal = (content: ReactNode | ReactFragment) => {
  const onClose = () => {
    ReactDOM.render(React.cloneElement(component, {visible: false}), div)
    ReactDOM.unmountComponentAtNode(div)
    div.remove()
  }
  const component = <Dialog onClose={onClose} visible={true}>
    {content}
  </Dialog>
  const div = document.createElement('div')
  document.body.appendChild(div)
  ReactDOM.render(component, div)
  return onClose;
}

最後多了一個 retrun onClose,因爲閉包的做用,外部調用返回的 onClose 方法能夠訪問到內部變量。

調用方式:

const openModal = () => {
  const close = modal(<h1>你好
    <button onClick={() => close()}>close</button>
  </h1>)
}
<button onClick={openModal}>modal</button>

重構 API

在重構以前,咱們先要抽象 alert, confirm, modal 中各自的方法:

alert confirm modal
onClose onClose * 2 onClose
component component component
render render render
return api

從表格能夠看出,modal 與其它兩個只多了一個 retrun api,其實其它兩個也能夠返回對應的 Api,只是咱們沒去調用而已,因此補上:

alert confirm modal
onClose onClose * 2 onClose
component component component
render render render
return api return api return api

這樣一來,這三個函數從抽象層面上來看是相似的,因此這三個函數應該合成一個。

首先抽取公共部分,先取名爲x ,內容以下:

const x= (content: ReactNode, buttons ?:Array<ReactElement>, afterClose?: () => void) => {
  const close = () => {
    ReactDOM.render(React.cloneElement(component, {visible: false}), div)
    ReactDOM.unmountComponentAtNode(div)
    div.remove()
    afterClose && afterClose()
  }
  const component = 
  <Dialog visible={true} 
    onClose={() => {
      close(); afterClose && afterClose()
    }}
    buttons={buttons}
  >
    {content}
  </Dialog>
  const div = document.createElement('div')
  document.body.append(div)
  ReactDOM.render(component, div)
  return close
}

alert 重構後的代碼以下:

const alert = (content: string) => {
  const button = <button onClick={() => close()}>ok</button>
  const close = x(content, [button])
}

confirm 重構後的代碼以下:

const confirm = (content: string, yes?: () => void, no?: () => void) => {

  const onYes = () => {
    close()
    yes && yes()
  }
  const onNo = () => {
    close()
    no && no()
  }
  const buttons = [
    <button onClick={onYes}>yes</button>, 
    <button onClick={onNo}>no</button>
  ]
  const close =  modal(content, buttons, no)
}

modal 重構後的代碼以下:

const modal = (content: ReactNode | ReactFragment) => {
  return x(content)
}

最後發現其實 x 方法就是 modal 方法,因此更改 x 名爲 modal,刪除對應的 modal 定義。

總結

  1. scopedClass 高階函數的使用
  2. <Fragment>
  3. 傳送門 portal
  4. 動態生成組件
  5. 閉包傳 API

本組件爲使用優化樣式,若是有興趣能夠自行優化,本節源碼已經上傳至這裏中的lib/dialog

參考

方應杭老師的React造輪子課程

你的點贊是我持續分享好東西的動力,歡迎點贊!

clipboard.png

相關文章
相關標籤/搜索