精讀《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 切換是否爲手動按 Enter 或 Space 鍵:

<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 後,Tab 與 Tab.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;
}

所有子組件如 Tab、Tab.Panel、Tab.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 內已知該 Tab 的 index 與 selectedIndex,那麼給用戶提供一個組合變量 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