React 模態框祕密和“輪子”漸進設計

LucasHC發表於2017-10-23

今天上午組內小朋友們談到 React 實踐,提到 React 模態框(彈窗)的使用。我發現很多一些 React 開發者對於 React 模態框的具體設計思路和實現存在一些疑惑。因而特寫此文,分享我對模態框這個“重要且典型”的前端互動,在 React 框架裡實現的一些想法。準備時間短促且匆忙,難免有遺漏之處,希望大神給予斧正。

這篇文章“進階式”漸進地,由淺入深分析三種實現。從最初的簡單粗暴到接近 react-modal 庫設計思想,一步步打磨分析,適合初學者閱讀思考。

原始級實現 —— 暴力美學

世界上大部分網站都離不開模態框互動。事實上,模態框就是我們俗稱的“彈窗”,只不過這個彈窗相比簡單的:

alert('我是一個簡單、原生的 alert~');複製程式碼

多了更多的資訊承載和互動行為。同時為了更佳美化和吸引眼球,模態框往往伴隨著深色透明的遮罩。比如下圖:

模態框舉例
模態框舉例

想想常見的使用者登入框、錯誤資訊提示等等,都是非常典型的模態框實現。

在傳統的 jQuery 操作 DOM 類庫的技術棧下,我們可以“肆無忌憚”地選擇 DOM 節點,完成 append, remove 等操作,實現模態框並不複雜。可是在 React 和 Redux 世界裡,我們該如何實現?

我們先來看一下場景和初版設計思路:

場景和初版設計思路
場景和初版設計思路

如圖,箭頭標記的元件需要觸發模態框的出現。
圖中元件樹對應基本頁面程式碼如下:

export default class App extends Component {
    render() {
        <div className="app">
            <div className="left">
                <h1>Hello left</h1>
                // ...
            </div>

            <div className="right">
                <h1>Hello right</h1>
                // ...
                <div>
                    <BadModal>
                        // 模態框內容
                        <h1> Modal title </h1>
                        <p> Modal content</p>
                    </BadModal>
                </div>
            </div>
        </div>
    }
}複製程式碼

細心的讀者會發現,作為 amazing 的程式設計師,儘管這是最初版本的實現,但是還是思考一些最基本的“複用”問題。

我們設計完成的模態框元件 ,因為每個模態框裡內容和互動不盡相同,所以在 元件內,我們渲染 child component,這個 child component 即業務對應的模態框內容,它將會由業務邏輯開發完成,實現模態框內容、互動的複用。如下程式碼:

class BadModal extends Comment {
    render() {
        return (
            <div className="modal">
                { this.props.children }
            </div>
        )
    }
}複製程式碼

至此,我們已經實現了最基本的模態框。可是為什麼說這是最原始、簡陋的方法呢?細想一下,似乎不完美的地方還很多。翻開我們的樣式表:

body .modal {
    position: fixed;
    // ...
}
.left {
    z-index: 3
}
.right {
    z-index: 1
}複製程式碼

你會發現惱人的 z-index 問題,我們模態框是 .right 節點的子孫節點,而 .right 的 z-index 小於 .left 的 z-index,這樣造成的直接問題就是模態框最終不能脫離頁面整體而“突出顯示”!

細想一下,這個問題的根本就出現在我們的元件設計圖中。
仔細觀察上圖,因為很深層次的子孫元件觸發模態框,而使得該元件內的模態框元件層級較深。如果你對 z-index 比較規則有所瞭解的話,這樣的情況很難完成模態框凌駕於頁面整體而出現的,遮罩也無法覆蓋整個頁面。

想想我們平時使用的 jQuery 是怎麼做的吧:

$('body').append('<div class="overlay"></div>');複製程式碼

一般情況,模態框和遮罩總是作為在 body 下的第一層子節點出現。由此,引出了我們的第二種進階思路。請讀者繼續閱讀。

實現方案二 —— 乾坤大挪移

解決方法很簡單,我們可以很自然地想到:只需要對 元件出現的位置進行移動。可是這就需要 元件和觸發模態框出現的深層次元件進行某種意義上的通訊。

傳統的 React 元件間通訊無外乎 props 和基於 props 的回撥實現(不考慮 context 的黑魔法)。可是這樣的做法太過複雜,也難以實現複用,更不利於維護。

至於我這裡採用的做法,還要從調整後的頁面元件樹設計出發:

如圖,我們在 document.body 下加入了 元件,並列於 Root Component。同時,至關重要的一步設計是,我們在觸發模態框的元件下,加入了一個 Fake Modal 元件。

這個神祕的 Fake Modal 元件做了什麼呢?
事實上,他並不渲染任何結果,而是藉助其生命週期函式,完成在 document.body 下新建並插入 元件的使命。

藉助程式碼進行理解:

class Modal extends Comment {
    componentDidMount() {
        this.modalTarget = document.createElement('div');
        this.modalTarget.className = 'modal';
        document.body.appendChild(this.modalTarget);
        this.renderModal();
    }
    componentWillUpdate() {
        this.renderModal();
    }
    componentWillUnmount() {
        ReactDom.unmountComponentAtNode(this.modalTarget);
        document.body.removeChild(this.modalTarget)l
    }
    renderModal() {
        ReactDom.render(
            <div>{ this.props.children }</div>,
            this.modalTarget
        );
    }
    render() {
        return <noscript />
    }
}複製程式碼

具體進行分析在真正的 render 方法中,我們不渲染任何實質的內容,而是:

return <noscript />;複製程式碼

同時,藉助生命週期函式 componentDidMount,我們使用原生 JavaScript 實現在 body 下的模態框建立:

this.modalTarget = document.createElement('div');
this.modalTarget.className = 'modal';
document.body.appendChild(this.modalTarget);
this.renderModal();複製程式碼

並最終呼叫 renderModal 方法完成插入:

ReactDom.render(
    <div>{ this.props.children }</div>,
    this.modalTarget
);複製程式碼

實現方案三 —— 搭配 Redux

相信很多 React 開發者都會使用 Redux 來做資料管理。仔細看上圖的結構中,我們難以實現對 Redux 的友好相容。

image.png
image.png

圖片

比如說,如果在 元件的子元件 child component 中,需要使用 Redux store 裡的資料,那麼因為 實質上是一個“高階元件”且不在 元件的元件鏈中,因為 child component 無法感知 Redux store 的存在。

為了解決這個問題,我們繼續改進元件樹結構為:

image.png
image.png

圖片

為此,我們引入應用的 store,以及 react-redux 包提供的 元件:

import { store } from '../index';
import { Provider } from 'react-redux';複製程式碼

同時改動先前的 renderModal 方法,加入對 的支援:

renderModal() {
    ReactDom.render(
        <Provider store={ store }>
            <div>{ this.props.children }</div>
        <Provider>,
        this.modalTarget
    );
}複製程式碼

著名的 react-modal 探祕和 React16 版本驚喜

在 React 開發中,我想很多工程師對 react-modal 非常熟悉。我們往往依賴它,完成模態框的使用。

這個庫設計良好,請封裝完善。如果你好奇它是如何實現的,原始碼又是如何組織?那麼我可以告訴你,你已經瞭解了他的設計哲學。事實上,文章介紹的思路就是它的奧祕。

瞭解了這些,你也可以動手實現“一個輪子”,或者擴充本文原始碼,實現更多的功能。比如樣式的自定義、彈出前後的回撥等等。相信一定會有很多收穫。

同時,React 最新版本 0.16 已經橫空出世,它帶來的很多新特性之一就與本文密切相關。那就是 —— Portal,Portal 我們把它翻譯為“傳送門、任意門”。Portals 允許將元件渲染到父節點之外的 DOM 節點中。它的基本使用如下程式碼示例:

render() {
    return ReactDOM.createPortal(
                this.props.children,
                anyDomNode,
            );
}複製程式碼

這裡 React 並不會在當前結構中渲染元件,而是向 anyDomNode 中渲染 this.props.children,這裡的 anyDomNode 是任何有效的DOM節點,無論它處於哪個層級位置。

瞭解了這些,我們當然能夠使用此特性,簡化上文邏輯。翻開 react-modal 最新提交的原始碼,便能夠發現對這一新特性的支援,react-modal/src/components/Modal.js 檔案中:

const isReact16 = ReactDOM.createPortal !== undefined;
const createPortal = isReact16
  ? ReactDOM.createPortal
  : ReactDOM.unstable_renderSubtreeIntoContainer;複製程式碼

這裡對 React 版本進行判斷,並設定 isReact16 標識位表示是否支援 createPortal 的方法(個人認為這個標識位的命名非常不合適...)

最終在 render 方法內:

render() {
    if (!canUseDOM || !isReact16) {
        return null;
    }

    if (!this.node && isReact16) {
        this.node = document.createElement("div");
    }

    return createPortal(
        <ModalPortal
            ref={this.portalRef}
            defaultStyles={Modal.defaultStyles}
            {...this.props}
        />,
        this.node
    );
}複製程式碼

非常明顯地看到,對於不支援 createPortal 的情況採用與我們類似的 return null; 否則愉快地使用 createPortal 方法。

總結

本文介紹內容雖然基礎,但是很好地貫穿了 React 思想以及實現一個“模態框輪子”的演進思路。同時介紹了 React 新版本的一項特性。

我的其他幾篇關於React技術棧的文章:
React Redux 中介軟體思想遇見 Web Worker 的靈感(附demo)
瞭解 Twitter 前端架構 學習複雜場景資料設計
React 探祕 - React Component 和 Element(文末附彩蛋demo和原始碼)
從setState promise化的探討 體會React團隊設計思想
通過例項,學習編寫 React 元件的“最佳實踐”
React 元件設計和分解思考
從 React 繫結 this,看 JS 語言發展和框架設計
React 服務端渲染如此輕鬆 從零開始構建前後端應用
做出Uber移動網頁版還不夠 極致效能打造才見真章**
React+Redux打造“NEWS EARLY”單頁應用 一個專案理解最前沿技術棧真諦**

Happy Coding!
PS: 作者 Github倉庫**知乎問答連結 歡迎各種形式交流。

相關文章