React 模態框秘密和「輪子」漸進設計

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

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

原始級實現 —— 暴力美學

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

alert(我是一個簡單、原生的 alert~);n

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

想想常見的用戶登錄框、錯誤信息提示等等,都是非常典型的模態框實現。

在傳統的 jQuery 操作 DOM 類庫的技術棧下,我們可以「肆無忌憚」地選擇 DOM 節點,完成 append, remove 等操作,實現模態框並不複雜。

可是在 React 和 Redux 世界裡,我們該如何實現?

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

如圖,箭頭標記的組件需要觸發模態框的出現。

圖中組件樹對應基本頁面代碼如下:

export default class App extends Component {n render() {n <div className="app">n <div className="left">n <h1>Hello left</h1>n // ...n </div>nn <div className="right">n <h1>Hello right</h1>n // ...n <div>n <BadModal>n // 模態框內容n <h1> Modal title </h1>n <p> Modal content</p>n </BadModal>n </div>n </div>n </div>n }n}n

細心的讀者會發現,儘管這是最初版本的實現,但是還是思考一些最基本的「復用」問題。

我們設計完成的模態框組件 <BadModal>,考慮到因為每個模態框里內容和交互不盡相同,所以在 <BadModal> 組件內,選擇了渲染 child component,這個 child component 即業務所定義的模態內容,它將會由業務邏輯開發完成,實現模態框內容、交互的復用。如下代碼:

class BadModal extends Comment {n render() {n return (n <div className="modal">n { this.props.children }n </div>n )n }n}n

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

翻開我們的樣式表:

body .modal {n position: fixed;n // ...n}n.left {n z-index: 3n}n.right {n z-index: 1n}n

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

細想一下,z-index 這個問題的根本原因就出現在我們的組件設計圖中。

仔細觀察上圖,因為很深層次的子孫組件觸發模態框,而使得該組件內的模態框組件層級較深。如果你對 z-index 比較規則有所了解的話,這樣的情況很難完成模態框凌駕於頁面整體而出現的,遮罩也無法覆蓋整個頁面。

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

$(body).append(<div class="overlay"></div>);n

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

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

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

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

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

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

這個神秘的 Fake Modal 組件做了什麼呢?

事實上,他並不渲染任何結果,而是藉助其生命周期函數,完成在 document.body 下新建並插入 <Modal> 組件的使命。

藉助代碼進行理解:

class Modal extends Comment {n componentDidMount() {n this.modalTarget = document.createElement(div);n this.modalTarget.className = modal;n document.body.appendChild(this.modalTarget);n this.renderModal();n }n componentWillUpdate() {n this.renderModal();n }n componentWillUnmount() {n ReactDom.unmountComponentAtNode(this.modalTarget);n document.body.removeChild(this.modalTarget)ln }n renderModal() {n ReactDom.render(n <div>{ this.props.children }</div>,n this.modalTargetn );n }n render() {n return <noscript />n }n}n

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

return <noscript />;n

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

this.modalTarget = document.createElement(div);nthis.modalTarget.className = modal;ndocument.body.appendChild(this.modalTarget);nthis.renderModal();n

並最終調用 renderModal 方法完成插入:

ReactDom.render(n <div>{ this.props.children }</div>,n this.modalTargetn);n

實現方案三 —— 搭配 Redux

相信很多 React 開發者都會使用 Redux 來做數據管理。在上圖的結構中,我們難以實現對 Redux 的友好兼容。

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

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

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

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

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

renderModal() {n ReactDom.render(n <Provider store={ store }>n <div>{ this.props.children }</div>n <Provider>,n this.modalTargetn );n}n

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

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

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

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

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

render() {n return ReactDOM.createPortal(n this.props.children,n anyDomNode,n );n}n

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

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

const isReact16 = ReactDOM.createPortal !== undefined;nconst createPortal = isReact16n ? ReactDOM.createPortaln : ReactDOM.unstable_renderSubtreeIntoContainer;n

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

最終在 render 方法內:

render() {n if (!canUseDOM || !isReact16) {n return null;n }nn if (!this.node && isReact16) {n this.node = document.createElement("div");n }nn return createPortal(n <ModalPortaln ref={this.portalRef}n defaultStyles={Modal.defaultStyles}n {...this.props}n />,n this.noden );n}n

非常明顯地看到,對於不支持 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倉庫 和 知乎問答鏈接 歡迎各種形式交流。


推薦閱讀:

高性能 MobX 模式(part 3)- 用例教程
React 許可證雖嚴苛,但不必過度 react
React全家桶實現一個簡易備忘錄
The Redux Journey 翻譯及分析(上)

TAG:React | 前端开发 | 前端工程师 |