精讀《Headless 組件用法與原理》

Headless 組件即無 UI 組件,框架僅提供邏輯,UI 交給業務實現。這樣帶來的好處是業務有極大的 UI 自定義空間,而對框架來說,只考慮邏輯可以讓自己更輕鬆的覆蓋更多場景,滿足更多開發者不同的訴求。

我們以 headlessui-tabs 爲例看看它的用法,並讀一讀 源碼。

概述

headless tabs 最簡單的用法如下:

import { Tab } from "@headlessui/react";

function MyTabs() {
  return (
    <Tab.Group>
      <Tab.List>
        <Tab>Tab 1</Tab>
        <Tab>Tab 2</Tab>
        <Tab>Tab 3</Tab>
      </Tab.List>
      <Tab.Panels>
        <Tab.Panel>Content 1</Tab.Panel>
        <Tab.Panel>Content 2</Tab.Panel>
        <Tab.Panel>Content 3</Tab.Panel>
      </Tab.Panels>
    </Tab.Group>
  );
}

以上代碼沒有做任何邏輯定製,只用 Tab 及其提供的標籤把 tabs 的結構描述出來,此時框架能提供最基礎的 tabs 切換特性,即按照順序,點擊 Tab 時切換內容到對應的 Tab.Panel

此時沒有任何額外的 UI 樣式,甚至連 Tab 選中態都沒有,如果需要進一步定製,需要用框架提供的 RenderProps 能力拿到狀態後做業務層的定製,比如選中態:

<Tab as={Fragment}>
  {({ selected }) =(
    <button
      className={selected ? "bg-blue-500 text-white" : "bg-white text-black"}
    >
      Tab 1
    </button>
  )}
</Tab>

要實現選中態就要自定義 UI,如果使用 RenderProps 拓展,那麼 Tab 就不應該提供任何 UI,所以 as={Fragment} 就表示該節點作爲一個邏輯節點而非 UI 節點(不產生 dom 節點)。

類似的,框架將 tabs 組件拆分爲 Tab 標題區域 Tab 與 Tab 內容區域 Tab.Panel,每個部分都可以用 RenderProps 定製,而框架早已根據業務邏輯規定好了每個部分可以做哪些邏輯拓展,比如 Tab 就提供了 selected 參數告知當前 Tab 是否處於選中態,業務就可以根據它對 UI 進行高亮處理,而框架並不包含如何做高亮的處理,因此才體現出該 tabs 組件的拓展性,但響應的業務開發成本也較高。

Headless 的拓展性可以拿一個場景舉例:如果業務側要定製 Tab 標題,我們可以將 Tab.List 包裹在一個更大的標題容器內,在任意位置添加標題 jsx,而不會破壞原本的 tabs 邏輯,然後將這個組件作爲業務通用組件即可。

再看更多的配置參數:

控制某個 Tab 是否可編輯:

<Tab disabled>Tab 2</Tab>

Tab 切換是否爲手動按 EnterSpace 鍵:

<Tab.Group manual>

默認激活 Tab:

<Tab.Group defaultIndex={1}>

監聽激活 Tab 變化:

<Tab.Group
  onChange={(index) ={
    console.log('Changed selected tab to:', index)
  }}
>

受控模式:

<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>

用法就介紹到這裏。

精讀

由此可見,Headless 組件在 React 場景更多使用 RenderProps 的方式提供 UI 拓展能力,因爲 RenderProps 既可以自定義 UI 元素,又可以拿到當前上下文的狀態,天然適合對 UI 的自定義。

還有一些 Headless 框架如 TanStack table 還提供了 Hooks 模式,如:

const table = useReactTable(options)

return <table {table.getTableProps()}></table>

Hooks 模式的好處是沒有 RenderProps 那麼多層回調,代碼層級看起來舒服很多,而且 Hooks 模式在其他框架也逐漸被支持,使組件庫跨框架適配的成本比較低。但 Hooks 模式在 React 場景下會引發不必要的全局 ReRender,相比之下,RenderProps 只會將重渲染限定在回調函數內部,在性能上 RenderProps 更優。

分析的差不多,我們看看 headlessui-tabs 的 源碼。

首先組件要封裝的好,一定要把內部組件通信問題給解決了,即爲什麼包裹了 Tab.Group 後,TabTab.Panel 就可以產生聯動?它們一定要訪問共同的上下文數據。答案就是 Context:

首先在 Tab.Group 利用 ContextProvider 包裹一層上下文容器,並封裝一個 Hook 從該容器提取數據:

// 導出的別名就叫 Tab.Group
const Tabs = () ={
  return (
    <TabsDataContext.Provider value={tabsData}>
      {render({
        ourProps,
        theirProps,
        slot,
        defaultTag: DEFAULT_TABS_TAG,
        name: "Tabs",
      })}
    </TabsDataContext.Provider>
  );
};

// 提取數據方法
function useData(component: string) {
  let context = useContext(TabsDataContext);
  if (context === null) {
    let err = new Error(
      `<${component} /> is missing a parent <Tab.Group /> component.`
    );
    if (Error.captureStackTrace) Error.captureStackTrace(err, useData);
    throw err;
  }
  return context;
}

所有子組件如 TabTab.PanelTab.List 都從 useData 獲取數據,而這些數據都可以從當前最近的 Tab.Group 上下文獲取,所以多個 tabs 之間數據可以相互隔離。

另一個重點就是 RenderProps 的實現。其實早在 75. 精讀《Epitath 源碼 - renderProps 新用法》 我們就講過 RenderProps 的實現方式,今天我們來看一下 headlessui 的封裝吧。

核心代碼精簡後如下:

function _render<TTag extends ElementType, TSlot>(
  props: Props<TTag, TSlot> & { ref?: unknown },
  slot: TSlot = {} as TSlot,
  tag: ElementType,
  name: string
) {
  let {
    as: Component = tag,
    children,
    refName = 'ref',
    ...rest
  } = omit(props, ['unmount''static'])

  let resolvedChildren = (typeof children === 'function' ? children(slot) : children) as
    | ReactElement
    | ReactElement[]

  if (Component === Fragment) {
    return cloneElement(
      resolvedChildren,
      Object.assign(
        {},
        // Filter out undefined values so that they don't override the existing values
        mergeProps(resolvedChildren.props, compact(omit(rest, ['ref']))),
        dataAttributes,
        refRelatedProps,
        mergeRefs((resolvedChildren as any).ref, refRelatedProps.ref)
      )
    )
  }

  return createElement(
    Component,
    Object.assign(
      {},
      omit(rest, ['ref']),
      Component !== Fragment && refRelatedProps,
      Component !== Fragment && dataAttributes
    ),
    resolvedChildren
  )
}

首先爲了支持 Fragment 模式,所以當制定 as={Fragment} 時,就直接把 resolvedChildren 作爲子元素,否則自己就作爲 dom 載體 createElement(Component, ..., resolvedChildren) 來渲染。

而體現 RenderProps 的點就在於 resolvedChildren 處理的這段:

let resolvedChildren =
  typeof children === "function" ? children(slot) : children;

如果 children 是函數類型,就把它當做函數執行並傳入上下文(此處爲 slot),返回值是 JSX 元素,這就是 RenderProps 的本質。

再看上面 Tab.Group 的用法:

render({
  ourProps,
  theirProps,
  slot,
  defaultTag: DEFAULT_TABS_TAG,
  name: "Tabs",
});

其中 slot 就是當前 RenderProps 能拿到的上下文,比如在 Tab.Group 中就提供 selectedIndex,在 Tab 就提供 selected 等等,在不同的 RenderProps 位置提供便捷的上下文,對用戶使用比較友好是比較關鍵的。

比如 Tab 內已知該 TabindexselectedIndex,那麼給用戶提供一個組合變量 selected 就可能比分別提供這兩個變量更方便。

總結

我們總結一下 Headless 的設計與使用思路。

作爲框架作者,首先要分析這個組件的業務功能,並抽象出應該拆分爲哪些 UI 模塊,並利用 RenderProps 將這些 UI 模塊以 UI 無關方式提供,並精心設計每個 UI 模塊提供的狀態。

作爲使用者,瞭解這些組件分別支持哪些模塊,各模塊提供了哪些狀態,並根據這些狀態實現對應的 UI 組件,響應這些狀態的變化。由於最複雜的狀態邏輯已經被框架內置,所以對於 UI 狀態多樣的業務甚至可以每個組件重寫一遍 UI 樣式,對於樣式穩定的場景,業務也可以按照 Headless + UI 作爲整體封裝出包含 UI 的組件,提供給各業務場景調用。

討論地址是:精讀《Headless 組件用法與原理》· Issue #444 · dt-fe/weekly

如果你想參與討論,請 點擊這裏,每週都有新的主題,週末或週一發佈。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公衆號

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/-PSUUDk0JtYRYH7g0viTZg