漫談 React 元件庫開發(一):多層巢狀彈層元件

有贊前端發表於2017-08-25

引言

UI 元件中有很多彈出式元件,常見的如 DialogTooltip 以及 Select 等。這些元件都有一個特點,它們的彈出層通常不是渲染在當前的 DOM 樹中,而是直接插入在 body (或者其它類似的地方)上的。這麼做的主要目的是方便控制這些彈出層的 z-index ,確保它們能夠處於合適的層級上,不至於被遮擋。

我們都知道 React App 的頂層某個地方肯定有這麼一行程式碼:ReactDOM.render(<App />, mountNode),這個 API 呼叫的作用是在 mountNode 的位置建立一棵 React 的渲染樹,React 會接管 mountNode 開始的這棵 DOM 樹。

在 React 的這種管理模式下,會發現使用彈層似乎不太方便,因為元件樹是逐層往下生長的,但React 的 API 中並沒有直接提供跳出這棵元件樹的方法[注1]

所以,為了實現彈層元件,我們需要先實現一個 Portal 元件(玩遊戲的都知道,這是傳送門的意思),這個元件只做一件事:將元件樹中某些節點移出當前的DOM 樹,並且渲染到指定的 DOM 節點中。

Portal 元件

Portal 元件的要做的事情很簡單,render 函式因為不需要在當前位置輸出任何東西,所以直接返回 null 就可以了,剩下的就是在元件的生命週期中去手動管理要渲染到指定位置的那些元件。

// 簡化的 Portal 實現
class Portal extends Component {
  static propTypes = {
    children: PropTypes.node.isRequired,
    container: PropTypes.object.isRequired
  };

  render() {
    return null;
  }

  componentDidMount() {
    const { children, container } = this.props;
    mountChildrenAtNode(children, container);
  }

  componentWillUnmount() {
    const { container } = this.props;
    unmountChildrenAtNode(container);
  }
}複製程式碼

剩下唯一的問題是 mountChildrenAtNode 這個函式怎麼實現?仔細的同學應該已經發現了,這個函式和 ReactDOM.render 非常像,仔細一想,其實它們做的事情就是一樣的。所以我們直接用 ReactDOM.render 去替換 mountChildrenAtNode 就可以了。

那麼真的這麼簡單嗎?

是,但也不是。

說是,是因為邏輯上這程式碼並沒有什麼問題,而且大部分場景下是確實可以完美工作。

說不是,是因為剩下的小部分場景下這段程式碼確實存在很嚴重的問題。

那麼問題是什麼呢?

別急,我們先聊點別的。

相信大部分 React 開發者都用過 redux(至少聽過吧),react-redux 這個 binding 庫提供了連線 React 和 redux 的一個橋樑。react-redux 的實現依賴 React 很有用的一個功能Context,簡單來說 context 就是提供了一個方便的跨越層級往下傳遞資料的方式。

ReactDOM.render 的問題正是在於這個 context 的功能,它無法連線兩棵 React 元件樹的 context

ReactDOM.render 的函式原型中並沒有當前元件樹的資訊,而 context 是跟元件樹有關的。

ReactDOM.render(
  element,
  container,
  [callback]
)複製程式碼

解決這個問題的方法也很簡單,這裡也不賣關子了,React 提供了另一個非公開 API:ReactDOM.unstable_renderSubtreeIntoContainer。這個 API 多了一個引數,這個引數就是用來指定新的 React 元件樹根節點的父元件的,有了這個引數,兩棵本來互不相干的 React 元件樹就被聯絡起來了,同時它們的 context 也連線了起來。

ReactDOM.unstable_renderSubtreeIntoContainer(
  parentComponent,
  element,
  container,
  [callback]
)複製程式碼

想更好的瞭解 Context 的同學可以自己 Google,這不是本文重點,這裡不做展開了。

Portal 元件的可擴充套件性

不同的 UI 元件對彈層可能會有不同的功能需求,舉個例子, Dialog 元件需要在彈出的時候禁止頁面滾動,同時有些場景下需要支援點選背景部分關閉,或者按 ESC 鍵關閉。

這些很細節的功能點往往會出現需要不同組合的使用場景,例如只需要禁止滾動,或者同時需要禁止滾動和 ESC 鍵關閉。

一個很自然的想法是在 Portal 元件上加幾個可配置的 props 來控制這些功能。這麼做有個問題,不管使用者需不需要,程式碼都在那裡。

更好的方式是通過高階元件(HOC)的方式讓使用者自己去組合這些功能,這樣子沒有用到的功能並不會出現在最終的程式碼中。

說了這麼多關於 Portal 元件的實現細節,有興趣的同學可以去看看有讚的元件庫 Zent 裡面的 Portal 是如何實現的,大體上就是按上面說的那些方案做的。

彈層元件

有了 Portal 元件之後,基本上所有彈層元件都可以基於 Portal 去實現。例如 Dialog 無非就是在 Portal 元件的基礎上加了一些 CSS 樣式。複雜一點的元件例如 Select,需要實現一些觸發邏輯來控制彈層的開啟和關閉,比如 click 開啟或者 hover 開啟。我們接下來要討論的彈層元件正是特指類似 Select 中的這些彈層。

Zent 裡面有一個叫 Popover 的元件來處理這些複雜的彈層場景,Popover 封裝了常用的觸發邏輯,例如 click, hover, focus,同時 Popover 的觸發機制是可擴充套件的,使用者可以實現自己的觸發邏輯。

Popover 元件提供的另外一個重要功能是彈層的定位能力,也就是相對於 Trigger 的一個定位功能。除了內建的十幾種定位演算法,使用者可以實現自己的定位演算法來實現特殊場景下的需求。

有了 Popover 元件提供的觸發邏輯以及彈層定位這兩個功能之後,類似 Tooltip , Select 這樣的元件在實現時就完全不需要關心彈層的事了,只需要實現彈層內的元件邏輯就行了。

這裡已經能夠看出一個層次化的彈層元件設計了:Portal 負責脫離元件樹,PopoverPortal 的基礎上提供了更豐富的功能邏輯,其它元件又在 Popover 的基礎上去做封裝。這樣一種層次結構在實踐中大大降低了各類彈層元件的實現和維護成本。

在元件庫的設計中,這種對能力的抽象封裝是很重要的,在提高開發效率的同時也保證了各個元件行為的一致性。

乾貨:彈層元件的巢狀處理

上面介紹的彈層元件實現細節上並沒有特別之處,成熟的元件庫基本都是用類似方式實現的。但是 ZentPopover 元件實現了一個大多數 React 元件庫都沒有實現的功能:彈層的巢狀處理。

如果你還沒有明白這裡的彈層巢狀是什麼意思,沒關係,給你舉個例子就明白了。

如下圖,點選按鈕之後會彈出一個氣泡,這個氣泡中又有一個時間選擇器,所謂的彈層巢狀指的就是這種彈層之中又嵌了彈層的場景。正常的操作邏輯是滑鼠點選位置1的時候氣泡和時間選擇器同時關閉,但是點選位置2的時候應該只有時間選擇器關閉。

popover overlap
popover overlap

上面提到的點選兩個不同位置的不同行為其實就是彈層巢狀最主要的問題:上級的彈層元件應該知道哪個區域是屬於下級彈層元件的。

由於彈層元件的特殊性,它們在 DOM 樹中的位置跟它們實際的層次以及包含關係是沒有必然聯絡的,上圖中的兩個彈層是body 下面的兩個兄弟節點,但從彈層的角度看它們是有層次關係的,並不是並列的。

通常來說,彈層的層次結構也是一個樹狀結構,那麼處理巢狀問題最直接的想法就是每個彈層元件都各自維護一個子彈層的列表。當需要判斷點選是否在彈層外面時,不光要考慮當前彈層對應的 DOM 節點,還要考慮它的下級彈層對應的 DOM 節點。

這種方式處理的話需要手動維護這棵彈層的層級關係樹,包括樹中節點的插入/刪除,這些操作都不是很難。這個方法最大的問題在於,在 React 的體系內一個彈層元件很難跟不是它直接孩子(direct child)的子彈層互動。

ZentPopover 元件並沒有直接去維護這棵層級關係樹,而是利用了 React 中 context 的層級關係來避免自己去維護這棵樹。使用 context 的另一個附帶好處是,和非直接孩子的互動也不再是問題,因為 context 本身就是可以跨層級傳遞資訊的。Popover 的層級管理結構示意圖如下:

 *                context                       context
 *                ------>                       ------>
 * Popover Root               Popover child                    Popover grand-child     ......
 *                <------                       <------
 *             isOutsideQuery                isOutsideQuery複製程式碼

就是這麼一個很簡單的設計解決了 Zent 中彈層元件的層級巢狀問題,想了解實現細節的同學可以看 Popover 的原始碼

總結

彈層元件是 UI 元件庫中很重要的部分,一個逐層抽象的結構可以極大簡化這些元件的開發和維護成本。

合理利用 React 的 context 功能可以很方便地解決一些像巢狀彈層一樣看似很麻煩的問題。

如果覺得有所收穫,請給 Zent 點個 star 吧。

[注1]: React Fiber 中提供了一個新的 API:ReactDOM. unstable_createPortal ,這個 API 可以將一個元件渲染到指定的 DOM 節點內。

本文首發於有贊技術部落格

相關文章