漫談 React 組件庫開發(一):多層嵌套彈層組件

引言

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

咱們都知道 React App 的頂層某個地方確定有這麼一行代碼:ReactDOM.render(<App />, mountNode),這個 API 調用的做用是在 mountNode 的位置建立一棵 React 的渲染樹,React 會接管 mountNode 開始的這棵 DOM 樹。node

在 React 的這種管理模式下,會發現使用彈層彷佛不太方便,由於組件樹是逐層往下生長的,但React 的 API 中並無直接提供跳出這棵組件樹的方法[注1]react

因此,爲了實現彈層組件,咱們須要先實現一個 Portal 組件(玩遊戲的都知道,這是傳送門的意思),這個組件只作一件事:將組件樹中某些節點移出當前的DOM 樹,而且渲染到指定的 DOM 節點中。git

Portal 組件

Portal 組件的要作的事情很簡單,render 函數由於不須要在當前位置輸出任何東西,因此直接返回 null 就能夠了,剩下的就是在組件的生命週期中去手動管理要渲染到指定位置的那些組件。github

// 簡化的 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 就能夠了。算法

那麼真的這麼簡單嗎?redux

是,但也不是。bash

說是,是由於邏輯上這代碼並無什麼問題,並且大部分場景下是確實能夠完美工做。ide

說不是,是由於剩下的小部分場景下這段代碼確實存在很嚴重的問題。函數

那麼問題是什麼呢?

別急,咱們先聊點別的。

相信大部分 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 節點內。

本文首發於有贊技術博客

相關文章
相關標籤/搜索