在 React 如何實現一套優雅的 Toast 組件

前言

在業務開發中,特別是移動端的業務,Toast 使用很是頻繁,幾乎涵蓋全部操做結果反饋的交互:如提交表單成功、驗證表單失敗提示、loading 態提醒...,這種輕量且使用較爲頻繁的組件,咱們要求它使用足夠簡單,不侵入業務代碼,即用即丟,基於這些要求,Toast 組件的實現方式也與其餘組件有着不同的關鍵點,這也是本篇博客的存在乎義。html

關鍵點

使用足夠簡單

由於使用很是頻繁,且要求其隨地可用,所以,咱們但願只用一行代碼:react

Toast.info('this is a toast', 1000);
複製代碼

無需手動插入組件容器

咱們使用其餘諸如 antd 組件時,大部分的組件須要注入到業務 Dom 中,例如:git

render() {
    return (
        <div>other components...</div>
        <Dropdown overlay={menu}>
            <a className="ant-dropdown-link" href="#">
              Hover me <Icon type="down" />
            </a>
        </Dropdown>,
    )
}
複製代碼

然而由於 Toast 組件無需常駐頁面當中,即用即丟,且使用的位置變幻無窮,假如須要每次都在須要 Toast 的頁面當中手動注入組件的話,會很是影響效率和業務代碼的可維護性。github

多個 Toast 互不影響

業務中每每會存在多個提示同時發出的場景,好比兩個接口同時請求失敗須要同事提示錯誤緣由,那麼 Toast 就要求不能產生衝突。api

mini 版 toast

按照咱們書寫組件的慣性思惟,咱們實現一版最簡單的 toast,只須要知足其基本使用,實現後,咱們再分析其存在的問題。bash

實現

再不考慮上述關鍵點的狀況下,咱們書寫以下代碼來實現最簡單粗暴的 Toast:antd

class App extends React.Component {

    state = {
        isToastShow: false, // 是否展現 Toast
        toastText: '', // Toast 文字內容
    }

    // 設置 Toast 屬性
    handleToastShow = (toastText, showTime) => {
        this.setState({
            isToastShow: true,
            toastText
        });
        // 定時銷燬 Toast Dom
        setTimeout(() => {
            this.setState({
                isToastShow: false
            })
        }, showTime)
    }

    // 顯示 Toast
    handleShowToast = () => {
        this.handleToastShow('this is a toast', 3000)
    }

    render() {
        const { isToastShow, toastText } = this.state;
        return (
            <div>
                <button onClick={this.handleShowToast}>show toast</button>
                {isToastShow && <div className="toast-wrap">
                    <div className="toast-mask" />
                    <div className="toast-text">{toastText}</div>
                </div>}
            </div>
        )
    }
}
複製代碼

問題

這裏咱們發現了幾個問題:app

  1. 一個簡單的 Toast 居然須要定義兩個 state,增大了維護業務邏輯的心智,可維護性下降。
  2. 須要將 Toast 邏輯和 Dom,甚至是樣式,注入到業務代碼中,下降業務代碼的可讀性。
  3. 不能同時顯示多個 Toast

針對這些問題,接下來咱們逐步實現一個使用簡單方便的 Toast。dom

完整版實現

項目源碼地址: Vincedream/easy-toastide

調用方法

在講解組件實現前,咱們簡單地閱覽實現後的調用方法:

import React from 'react';
import Toast from './Toast';

function App () {
  const handleClick1 = () => {
    Toast.info('test111', 2000);
  }

  const handleClick2 = () => {
    Toast.info('test222', 1000, true);
  }

  const handleClick3 = () => {
    Toast.info('test333', 1000, true);
    Toast.info('test long duration', 4000, true);
  }

  const handleHideAllToast = () => {
    Toast.hide();
  }

  return(
    <div>
      <button onClick={handleClick1}>no mask Toast</button><br/>
      <button onClick={handleClick2}>with mask Toast</button><br/>
      <button onClick={handleClick3}>long duration</button><br/>
      <button onClick={handleHideAllToast}>hideAllToast</button>
    </div>
  )
}

export default App;
複製代碼

效果:

image

這裏,咱們調用了 Toast.info()後,動態地注入組件到 Dom 中,並無將 Toast 任何邏輯在業務容器中的 Dom 或者 Style 中注入。

動態注入 Dom 關鍵方法

咱們如何在不侵入容器的條件下,動態地注入 Dom,難道是像十年前 jQuery 時代去手動操做 Dom 嗎?確定不是的。這裏有個關鍵的方法:ReactDom.render(<組件/>, 真實 Dom),下面咱們看一個例子:

class App extends React.Component {

    handleAddDom = () => {
        // 在真實 dom 上建立一個真的的 div 宿主節點,並將其加入到頁面根節點 body 當中
        const containerDiv = document.createElement('div');
        document.body.appendChild(containerDiv);
        // 這裏返回的是對該組件的引用
        const TestCompInstance = ReactDom.render(<TestComp />, containerDiv);
        console.log(TestCompInstance);
        // 這裏能夠調用任何 TestCompInstance 上的方法,而且可以訪問到其 this
        TestCompInstance.sayName();
    }

    render() {
        return (
            <div>
                <button onClick={this.handleAddDom}>add Dom</button>
            </div>
        )
    }
}
複製代碼

執行結果:

image

從上面的例子咱們能夠看出,咱們能夠在 js 邏輯代碼中直接建立注入一個 React 組件到真實的 dom 中,而且能夠任意操控該組件,理解這點後,咱們便獲得了編寫 Toast 組件最核心的方法。

具體實現

首先,咱們建立一個 Toast 容器組件:

// ToastContainer.js
class ToastContainer extends Component {
    state = {
        isShowMask: false, // 當前 mask 是否顯示
        toastList: [] // 當前 Toast item 列表
    }
    
    // 將新的 toast push 到 toastContainer 中
    pushToast = (toastProps) => {
        const { type, text, duration, isShowMask = false } = toastProps;
        const { toastList } = this.state;
        toastList.push({
            id: getUuid(),
            type,
            text,
            duration,
            isShowMask
        });
        this.setState({
            toastList,
            isShowMask
        });
    }


    render() {
        const { toastList, isShowMask } = this.state;
        return (
            <div className="toast-container">
                {isShowMask && <div className="mask"/>}
                <div className="toast-wrap">
                    {toastList.reverse().map((item) => (
                        <Toast {...item} key={item.id} />
                    ))}
                </div>
            </div>
        );
    }
}
複製代碼

這個容器用來存放多個 Toast Item,用來控制 Toast 的顯示個數和是否展現 mask,而且將其渲染到容器當中,這裏面邏輯很是簡單。

接着咱們建立真正用來展現的 Toast Item 組件:

// ToastItem.js
class ToastItem extends Component {
    render() {
        const { text } = this.props;
        return (
            <div className="toast-item">
                {text}
            </div>
        );
    }
}
複製代碼

兩個關鍵組件已經建立完成,咱們須要「動態注入」將其渲染到 dom 中,使用上面講解的 ReactDom.render() 方法,爲此,咱們在建立一個 Toast 統一入口文件:

// index.js
import React from 'react';
import ReactDom from 'react-dom';

import ToastContainer from './ToastContainer';

// 在真實 dom 中建立一個 div 節點,而且注入到 body 根結點中,該節點用來存放下面的 React 組件
const toastContainerDiv = document.createElement('div');
document.body.appendChild(toastContainerDiv);

// 這裏返回的是 ToastContainer 組件引用
const getToastContainerRef = () => {
    // 將 <ToastContainer /> React 組件,渲染到 toastContainerDiv 中,而且返回了 <ToastContainer /> 的引用
    return ReactDom.render(<ToastContainer />, toastContainerDiv);
}

// 這裏是 <ToastContainer /> 的引用
let toastContainer = getToastContainerRef();


export default {
    info: (text, duration, isShowMask) => (toastContainer.pushToast({type: 'info', text, duration, isShowMask})),
};
複製代碼

這裏,咱們按照上面講解的 ReactDom.render() 方法, 將 <ToastContainer /> 渲染到 dom 中,而且得到了其引用,咱們只須要在這裏調用 <ToastContainer /> 中的 pushToast 方法,便能展現出 Toast 提示。

到這裏,咱們便完成了一個最簡化版的 動態注入版 Toast 組件,接下來的一節中,咱們將爲其添加如下兩個功能:

  1. 定時隱藏 Toast
  2. 強制隱藏 Toast

完善功能

定時隱藏 Toast

首先咱們改造 ToastContainer 容器組件,添加一個隱藏 mask 方法,並將其傳入到 <ToastItem /> 中:

class ToastContainer extends Component {
    ...

    // 將被銷燬的 toast 剔除
    popToast = (id, isShowMask) => {
        const { toastList } = this.state;
        const newList = toastList.filter(item => item.id !== id);
        this.setState({
            toastList: newList,
        });
        // 該 toast item 是否爲 toastList 中 duration 最長的 item
        let isTheMaxDuration = true;
        // 該 toast item 的 duration
        const targetDuration = toastList.find(item => item.id === id).duration;
        // 遍歷 toastList 檢查是否爲最長 duration
        toastList.forEach(item => {
            if (item.isShowMask && item.duration > targetDuration) {
                isTheMaxDuration = false
            }
            return null;
        });

        // 隱藏 mask
        if (isShowMask && isTheMaxDuration) {
            this.setState({
                isShowMask: false
            })
        }
    }

    render() {
        ...
        <ToastItem onClose={this.popToast} {...item} key={item.id} />
        ...     
    }
}
複製代碼

接着,咱們改造 <ToastItem />,在起 componentDidMount 中設置一個定時器,根據傳入的 duration 參數,設置隱藏 Toast 的定時器,而且在組件銷燬前,將定時器清除。

// ToastItem.js
class ToastItem extends Component {
    componentDidMount() {
        const { id, duration, onClose, isShowMask } = this.props;
        this.timer = setTimeout(() => {
            if (onClose) {
                onClose(id, isShowMask);
            }
        }, duration)
    }
    // 卸載組件後,清除定時器
    componentWillUnmount() {
        clearTimeout(this.timer)
    }
    render() {
       ...
    }
}
複製代碼

這裏咱們便完成了隱藏 Toast 的功能,其細節在代碼中有詳細的解釋,這裏再也不作贅述。

強制隱藏 Toast

如何強制的隱藏已經出現的 Toast 呢?這裏咱們依舊使用到 ReactDom 的 api:ReactDom.unmountComponentAtNode(container),這個方法的做用是從 Dom 中卸載組件,會將其事件處理器(event handlers)和 state 一併清除:

// index.js
...
// 這裏返回的是 ToastContainer 組件引用
const getToastContainerRef = () => {
    // 將 <ToastContainer /> React 組件,渲染到 toastContainerDiv 中,而且返回了 <ToastContainer /> 的引用
    return ReactDom.render(<ToastContainer />, toastContainerDiv);
}
// 這裏是 <ToastContainer /> 的引用
let toastContainer = getToastContainerRef();
const destroy = () => {
    // 將 <ToastContainer /> 組件 unMount,卸載組件
    ReactDom.unmountComponentAtNode(toastContainerDiv);
    // 再次建立新的 <ToastContainer /> 引用,以便再次觸發 Toast
    toastContainer = getToastContainerRef();
}


export default {
    ...
    hide: destroy
};
複製代碼

須要注意的是,卸載 <ToastContainer /> 後,須要再次建立一個新的、空的 <ToastContainer /> 組件,以便後續再次調用 Toast。

總結

本篇文章咱們用了新的一種方法來建立一個特殊的 React 組件,實踐了一些你或許沒有使用過的 ReactDom 方法,除了 Toast 組件,咱們還能用一樣的思路編寫其餘的組件,如 Modal、Notification 等組件。

參考:

ReactDOM

項目源碼地址: Vincedream/easy-toast

相關文章
相關標籤/搜索