今天上午組內小朋友們談到 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 的友好兼容。
圖片
比如說,如果在 <Modal> 組件的子組件 child component 中,需要使用 Redux store 里的數據,那么因為 <Provider> 實質上是一個“高階組件”且不在 <Modal> 組件的組件鏈中,因為 child component 無法感知 Redux store 的存在。
為了解決這個問題,我們繼續改進組件樹結構為:
圖片
為此,我們引入應用的 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倉庫** 和 知乎問答鏈接 歡迎各種形式交流。