首頁 > 軟體

無UI 元件Headless框架邏輯原理用法範例詳解

2022-10-20 14:03:03

概述

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

以上就是無UI 元件Headless框架邏輯原理用法範例詳解的詳細內容,更多關於無UI 元件Headless框架邏輯的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com