傳送門:React Portal

ass_ace發表於2020-10-15

轉載自:https://zhuanlan.zhihu.com/p/29880992

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也就會被呼叫到了。

相關文章