傳送門:React Portal
React v16增加了對Portal的直接支持,今天我們就來聊一聊Portal。
似乎所有說React Portal都直接用Portal這個單詞,沒聽過這詞的朋友可能覺得不知所云,其實,Portal可以有一個很形象的翻譯——「傳送門」。
什麼是傳送門?
曾經有一款遊戲就叫做Portal,玩家手上一桿很厲害很科幻的槍,朝牆上開一槍,就可以開出兩個「傳送門」,人鑽進這個傳送門,可以從另一個傳送門裡走出來,也就是說,兩個不同位置的傳送門之間形成了對接。
如果還不明白Portal是啥,那就拿范冰冰在《X戰警:逆轉未來》所演角色的GIF動圖來看吧。
你看一個哨兵機器人撲過來攻擊一個X戰警,范冰冰從一個傳送門裡神速穿越而來,順手又甩出兩個傳送門,讓哨兵機器人撲進了一個傳送門,從另一個傳送門一個踉蹌掉了出來,從而救了那個X戰警。
現在明白Portal是怎麼回事了吧。
為什麼React需要傳送門?
React Portal之所以叫Portal,因為做的就是和「傳送門」一樣的事情:render到一個組件裡面去,實際改變的是網頁上另一處的DOM結構。
在React的世界中,一切都是組件(參見《幫助你深入理解React》),用組件可以表示一切界面中發生的邏輯,不過,有些特例處理起來還比較麻煩,比如,某個組件在渲染時,在某種條件下需要顯示一個對話框(Dialog),這該怎麼做呢?
最直觀的做法,就是直接在JSX中把Dialog畫出來,像下面代碼的樣子。
<div class="foo"> <div> ... </div> { needDialog ? <Dialog /> : null }</div>
問題是,我們寫一個Dialog組件,就這麼渲染的話,Dialog最終渲染產生的HTML就存在於上面JSX產生的HTML一起了,類似下面這樣。
<div class="foo"> <div> ... </div> <div class="dialog">Dialog Content</div></div>
可是問題來了,對於對話框,從用戶感知角度,應該是一個獨立的組件,通常應該顯示在屏幕的最中間,現在Dialog被包在其他組件中,要用CSS的position屬性控制Dialog位置,就要求從Dialog往上一直到body沒有其他postion是relative的元素干擾,這……有點難為作為通用組件的Dialog,畢竟,誰管得住所有組件不用position呢。
還有一點,Dialog的樣式,因為包在其他元素中,各種樣式糾纏,CSS樣式太容易搞成一坨漿糊了。
看樣子這樣搞局限很多啊,行不通,有沒有其他辦法?
有一個其他辦法,就是在React組件樹的最頂層留一個元素專屬於Dialog,然後通過Redux或者其他什麼通訊方式給這個Dialog發送信號,讓Dialog顯示或者不顯示。
這種方法看起來還湊合著,但是,就這點事還要動用Redux有點高射炮打蚊子,而且,要控制兩個不用位置的組件,好麻煩。
而且,如果我們把Dialog做成一個通用組件,希望裡面的內容完全定製,這招就更加麻煩了。
<div class="foo"> <div> ... </div> { needDialog ? <Dialog> <header>Any Header</header> <section>Any content</section> </Dialog> : null }</div>
像上面那樣,我們既希望在組件的JSX中選擇使用Dialog,把Dialog用得像一個普通組件一樣,但是又希望Dialog內容顯示在另一個地方,就需要Portal上場了。
Portal就是建立一個「傳送門」,讓Dialog這樣的組件在表示層和其他組件沒有任何差異,但是渲染的東西卻像經過傳送門一樣出現在另一個地方。
React在v16之前的傳送門實現方法
在v16之前,實現「傳送門」,要用到兩個秘而不宣的React API
- unstable_renderSubtreeIntoContainer
- unmountComponentAtNode
第一個unstable_renderSubtreeIntoContainer,都帶上前綴unstable了,就知道並不鼓勵使用,但是沒辦法啊,不用也得用,還好React一直沒有deprecate這個API,一直挺到v16直接支持portal。這個API的作用就是建立「傳送門」,可以把JSX代表的組件結構塞到傳送門裡面去,讓他們在傳送門的另一端渲染出來。
第二個unmountComponentAtNode用來清理第一個API的副作用,通常在unmount的時候調用,不調用的話會造成資源泄露的。
一個通用的Dialog組件的實現差不多是這樣,注意看renderPortal中的注釋。
import React from "react";import {unstable_renderSubtreeIntoContainer, unmountComponentAtNode} from "react-dom";class Dialog extends React.Component { render() { return null; } componentDidMount() { const doc = window.document; this.node = doc.createElement("div"); doc.body.appendChild(this.node); this.renderPortal(this.props); } componentDidUpdate() { this.renderPortal(this.props); } componentWillUnmount() { unmountComponentAtNode(this.node); window.document.body.removeChild(this.node); } renderPortal(props) { unstable_renderSubtreeIntoContainer( this, //代表當前組件 <div class="dialog"> {props.children} </div>, // 塞進傳送門的JSX this.node // 傳送門另一端的DOM node ); }}
首先,render函數不要返回有意義的JSX,也就說說這個組件通過正常生命周期什麼都不畫,要是畫了,那畫出來的HTML/DOM就直接出現在使用Dialog的位置了,這不是我們想要的。
在componentDidMount裡面,利用原生API來在body上創建一個div,這個div的樣式絕對不會被其他元素的樣式干擾。
然後,無論componentDidMount還是componentDidUpdate,都調用一個renderPortal來往「傳送門」里塞東西。
總結,這個Dialog組件做得事情是這樣:
- 它什麼都不給自己畫,render返回一個null就夠了;
- 它做得事情是通過調用renderPortal把要畫的東西畫在DOM樹上另一個角落。
在renderPortal中,利用unstable_renderSubtreeIntoContainer函數往直前創建的div里塞JSX,這裡我們用的JSX是這樣。
<div class="dialog"> {props.children} </div>
因為是吧children畫出來,所以使用Dialog可以加上任意的子組件。
<Dialog> What ever shit <div>Hello</div> <p>World</p> </Dialog>
你看,所謂React Portal,就是能夠表面上渲染在一個地方,實際上渲染到了另一個地方。
是不是感覺好厲害,不光好厲害,而且像Dialog這樣的場景Portal簡直就是必不可少。
到了v16,React乾脆直接支持Portal,當然,v15還將被使用一段時間,所以大家看了上面的內容也不算浪費時間:-)
React v16的Portal支持
在v16中,使用Portal創建Dialog組件簡單多了,不需要牽扯到componentDidMount、componentDidUpdate,也不用調用API清理Portal,關鍵代碼在render中,像下面這樣就行。
import React from "react";import {createPortal} from "react-dom";class Dialog extends React.Component { constructor() { super(...arguments); const doc = window.document; this.node = doc.createElement("div"); doc.body.appendChild(this.node); } render() { return createPortal( <div class="dialog"> {this.props.children} </div>, //塞進傳送門的JSX this.node //傳送門的另一端DOM node ); } componentWillUnmount() { window.document.body.removeChild(this.node); }}
v16提供createPortal函數來創建「傳送門」,我個人覺得這個函數應該叫renderPortal好一些,因為組件的render函數除了mount時會被調用,update時也會被調用,update時還叫createPortal有點不大合適。
穿越Portal的事件冒泡
v16之前的React Portal實現方法,有一個小小的缺陷,就是Portal是單向的,內容通過Portal傳到另一個出口,在那個出口DOM上發生的事件是不會冒泡傳送回進入那一端的。
也就是說,這樣的代碼。
<div onClick={onDialogClick}> <Dialog> What ever shit </Dialog></div>
在Dialog畫出的內容上點擊,onDialogClick是不會被觸發的。
當然,這只是一個小小的缺陷,大部分場景下事件不傳過來也沒什麼大問題。
在v16中,通過Portal渲染出去的DOM,事件是會冒泡從傳送門的入口端冒出來的,上面的onDialogClick也就會被調用到了。
總結
React v16直接支持Portal,是因為Portal這個功能真的是必不可少,不然對話框這樣的場景都沒法應付。
我開了一個Live 《深入理解React v16新功能》,會根據應用場景介紹React v16的所有新功能,有興趣的朋友可以訂閱。
相關文章:
開通React知識星球
Live通告:幫助你深入理解React
感謝《深入淺出React和Redux》的所有讀者
推薦閱讀: