網站遷移公告:很高興各位讀者近年來的陪伴!本部落格目前正在緩步遷移至 jingsu.fyi (個人內容) 以及 parseweb.dev (技術相關內容)
文章

Design System 101 - 設計模式 x 複合組件

文章發表於

前言

React 是一個 Component-based 的 UI 庫,所以在設計組件時,我們會將組件拆分成一個個的小組件,並且將這些小組件像是樂高積木般的進行組合 (Compose) 成一個更複雜的組件。

這個概念我們稱為 Compound Component Pattern (複合組件設計模式),其核心就是讓多個組件組合後達到單一功能的效果 (例如 Menu 是由 Item, Trigger 等等所組合成的)。

這種寫法我們並不陌生,HTML 就是這種方式來撰寫,以原生的選單列表來舉例:

<h1>產品類別</h1>
<select>
<option>蘋果</option>
<option>香蕉</option>
<option>橘子</option>
</select>

在網頁應用中也可以看到許多 UI 的設計都是使用這種複合組件的概念,舉凡 Menu (下拉選單)、Select (選單) 以及 Accordion (手風琴) 等等。

然而這樣的模式下原生 API 可以運作的很好,但在 React 就迎來了一個問題,要如何讓父層與子層之間能夠互動?

<Menu>
<Menu.Item>蘋果</Menu.Item>
<Menu.Item>香蕉</Menu.Item>
<Menu.Item>橘子</Menu.Item>
</Menu>

開發過程中會遇到像是要如何讓 <Menu> 知道當前 active 的 <Menu.Item> 是哪一個,而 <Menu.Item> 也需要知道本身是否 active,進而顯示對應的 UI。

本篇將會介紹幾種常見的解決方式,以及它們的優缺點。

方法一:透過 `props`

用 props 來控制組件的行為,讓父子層透過 props 來傳遞資訊,這也是最多人選擇也是最簡單的方法。

實作

而 API 的設計會就是讓父層組件 (Menu) 控制所有子組件的行為,這樣的設計方式也是最常見的方式。

import React from 'react'
import { Menu } from './menu.js'

export default () => {
  return (
    <Menu
      defaultIndex={0}
      items={[
        { index: 0, label: '蘋果' },
        { index: 1, label: '香蕉' },
        { index: 2, label: '橘子' },
      ]}
    />
  )
}

想必各位看到這種 API 的設計應該不陌生,因為一些常見的 UI 庫就是以這種方式來設計的。像是 Ant Design - Menu 又或是 Grommet - Menu。

優缺點

優點

  • 這種方式非常直觀,在資料不複雜的情況下,可以使用這種方法來設計。

缺點

  • 此設計方式失去了彈性與擴充性,假設想要在某個 Item 上加 Icon 或是想要對 Item 改樣式,就會發現會需要傳入更多 props 來控制組件的行為,而當功能越來越多時,傳入的 API 就會變得相當多且複雜。

方法二:使用 `cloneElement`

另外一種方式就是用 cloneElement 的方式,這其實是實現複合組件模式的一種方式,透過 React.cloneElement 來將 index 與當前 active 的 index 傳給子組件。

而子組件 (MenuItem) 只要知道這些資訊後,就不需要依賴由單一組件 (Menu) 來控制所有行為。

實作

實作上就是透過 React.Children.map 迭代所有的子組件,並且透過 React.cloneElement 將 index, activeIndex 與 onClick 事件傳給子組件 (MenuItem)。

import React from 'react'
import { Menu, MenuItem } from './menu.js'

export default () => {
  return (
    <Menu defaultIndex={0}>
      <MenuItem>蘋果</MenuItem>
      <MenuItem>香蕉</MenuItem>
      <MenuItem>橘子</MenuItem>
      {/* uncomment to see the issue */}
      {/* 
        <div>
          <MenuItem>櫻桃</MenuItem>
       </div>
      */}
    </Menu>
  )
}

優缺點

優點

  • 解決上個方法擴充性的問題,也可以根據需求對 Item 放入相對應的 props。

缺點

  • 子組件的結構必須要固定,也就是當我們在 Item 加入一個 <div> 時,就會發生問題 (可以取消上面範例的註解來看看)。

方法三:React Descendant

React Descendant 主要是利用 React 渲染生命週期 (Render Lifecycle),在這過程中子組件會將其元素註冊到父層的 descendants,而父層就可以追蹤所有子組件並且管理它們的聚焦 (focus)。也讓我們可以不用使用 cloneElement 來傳遞 index。

API 設計

API 名稱說明
createDescendantContext用來建立 Descendant 的 Context,並且會將 children 透過 DescendantProvider 包起來 。
useDescendant回傳 index,並且會將子組件註冊到父層組件的 descendants 裡。
useDescendants回傳所有子組件的資訊。
useDescendantsInit回傳 descendants 與 setDescendants,並且會在第一次渲染時初始化 descendants。

實作

首先父層組件 (Menu) 會管理當前 active 的 index,以及所有 descendants 的資訊。

在渲染過程中,子組件 (MenuItem) 裡的 useDescendant 會找出組件自身的 index,同時透過 DescendantProvider 裡的 registerDescendant,將組件本身元素與相關資料註冊 (register) 到父層組件 descendants。

import React from 'react'
import { Menu, MenuItem } from './menu.js'

export default () => {
  return (
    <Menu defaultIndex={0}>
      <MenuItem>蘋果</MenuItem>
      <MenuItem>香蕉</MenuItem>
      <MenuItem>橘子</MenuItem>
      {/* uncomment to see the issue */}
      {/* 
        <div>
          <MenuItem>櫻桃</MenuItem>
       </div>
      */}
    </Menu>
  )
}

這樣就可以讓 index 不用透過父傳子的方式,而是子組件 (MenuItem) 找出自己的 index 後將其註冊到父層組件 (Menu) 的 descendants 裡,這樣使我們可以更容易管理它們的聚焦 (focus)。

同時父層組件 (Menu) 也只需要將當前 active 的 index 透過 Context 傳遞給子組件 (MenuItem),讓子組件自行判斷是否 active。

如果想要看更完整的 React Descendants 的實作方式,可以參考 Reach UI 的開源碼,可以透過它的測試情境了解更多以及管理 focus 的邏輯。

優缺點

優點

  • 解決了上面兩種方法的問題,同時也可以讓我們更容易管理子組件的聚焦 (focus)。

缺點

  • 雙重渲染,們的組件有大量的子組件時,會有大量的渲染,這會導致效能的問題。
  • 無法在 SSR (Server Side Rendering) 時使用,因爲任何需要知道 index 的事物(或是需要依賴 index 衍生出來的邏輯)在 SSR 中都還沒開始,直到第二次渲染才知道該資訊。
  • 無法在子組件中使用 <Suspense>,當異步 Item 渲染時,我們會失去其他所有 Item 的 index ,或者得到產生出重複的 index。

方法四:Collection API

最後介紹的是 Collection API,其概念跟 React Descendant 差不多,都是將子組件存在陣列裡,並且在子組件渲染時將自己的資訊註冊到父層組件的陣列裡。

API 設計

API 名稱說明
createCollection用來建立 Collection 的 API,並且會將 children 透過 CollectionComponent 包起來 。
useCollectionItem回傳兩個參數 ref 與 index, ref 用來將取得子組件的元素, index 則是該子組件的 index。
useCollectionItems回傳所有子組件的資訊。

實作

在 Menu 組件中,我們會透過 createCollection 來建立 Menu 的骨幹,它會將 Menu 的子組件包在 CollectionProvider 內。同時建立 MenuContext 來傳遞 activeIndex 與 setActiveIndex。

而在 MenuItem 組件中,我們會透過 useCollectionItem 來取得該子組件的 ref 與 index,並且可以透過 MenuContext 來取得 activeIndex 來呈現出對應的 UI。

import React from 'react'
import { Menu, MenuItem } from './menu.js'

export default () => {
  return (
    <Menu defaultIndex={0}>
      <MenuItem>蘋果</MenuItem>
      <MenuItem>香蕉</MenuItem>
      <MenuItem>橘子</MenuItem>
      {/* uncomment to see the issue */}
      {/* 
        <div>
          <MenuItem>櫻桃</MenuItem>
       </div>
      */}
    </Menu>
  )
}

優缺點

優點

  • 同樣是解決了上述介紹的前兩種方法的問題,一樣讓我們可以更容易管理子組件的聚焦 (focus)。
  • 支援 SSR (Server Side Rendering)。

參考資料

  1. Reach UI - Descendants
  2. Radix UI - Collection
  3. Advanced React component composition
  4. Advanced Element Composition in React
如果您喜歡這篇文章,請點擊下方按鈕分享給更多人,這將是對筆者創作的最大支持和鼓勵。