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

今天上午組內小朋友們談到 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 的程序員,盡管這是最初版本的實現,但是還是思考一些最基本的“復用”問題。

我們設計完成的模態框組件 <BadModal>,因為每個模態框里內容和交互不盡相同,所以在 <BadModal> 組件內,我們渲染 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 下的第一層子節點出現。由此,引出了我們的第二種進階思路。請讀者繼續閱讀。

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

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

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

至于我這里采用的做法,還要從調整后的頁面組件樹設計出發:

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

這個神秘的 Fake Modal 組件做了什么呢?
事實上,他并不渲染任何結果,而是借助其生命周期函數,完成在 document.body 下新建并插入 <Modal> 組件的使命。

借助代碼進行理解:

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

圖片

比如說,如果在 <Modal> 組件的子組件 child component 中,需要使用 Redux store 里的數據,那么因為 <Provider> 實質上是一個“高階組件”且不在 <Modal> 組件的組件鏈中,因為 child component 無法感知 Redux store 的存在。

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

image.png

圖片

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

import { store } from '../index';
import { Provider } from 'react-redux';

同時改動先前的 renderModal 方法,加入對 <Provider> 的支持:

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倉庫**知乎問答鏈接 歡迎各種形式交流。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,106評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,441評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,211評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,736評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,475評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,834評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,829評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,009評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,559評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,306評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,516評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,038評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,728評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,132評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,443評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,249評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,484評論 2 379

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,732評論 25 708
  • React的學習資源 這個文章好久沒有更新了,資源算比較老舊的了,畢竟前端更新還是非常快的。 半年不學習,都不知道...
    izhongxia閱讀 23,286評論 11 629
  • 謝阿呸姑娘邀請,今兒北京刮起了大風,又想起了與你在維多利亞灣吹著海風聊占中、聊文學的那個晚上,靠近海的城市真好。 ...
    羅羅磊磊閱讀 933評論 3 11
  • 今天下午的課,比較遺憾,沒有上完。 回想經過:7、8班的電腦都不能用,原定計劃不能進行,臨時不用課件。于是讓學...
    sunflower80閱讀 250評論 0 0
  • 2017年9月17日,周天。參加了一次本人人生當中最虐,風景最美的越野跑比賽,身體與心靈都經歷雙重的虐待,有史以來...
    強O閱讀 596評論 0 0