傳送門: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組件做得事情是這樣:

  1. 它什麼都不給自己畫,render返回一個null就夠了;
  2. 它做得事情是通過調用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》的所有讀者


推薦閱讀:

為什麼很多人都是設計師轉前端?
簡歷·面試-技術崗位技巧
不寫代碼時,我是一個諮詢師~

TAG:React | 前端开发 |