無頭元件:一個用於組成 React UI 的模式

隨著 React UI 控制項變得越來越複雜,複雜的邏輯可能會與視覺呈現交織在一起。這使得難以推論元件的行為、難以測試,並且需要建立需要不同外觀的類似元件。無頭元件會萃取所有非視覺邏輯和狀態管理,將元件的大腦與其外觀分開。

2023 年 11 月 7 日


Photo of Juntao QIU | 邱俊涛

邱俊濤是 Atlassian 的軟體工程師,熱衷於測試驅動開發、重構和乾淨的程式碼。他樂於分享自己的知識,並幫助其他開發人員成長。

邱俊濤也是一位作家,出版了多本關於該領域的書籍。此外,他是一位部落客、YouTuber 和內容創作者,協助人們撰寫更好的程式碼。


React 徹底改變了我們思考 UI 元件和 UI 中狀態管理的方式。但是,隨著每個新的功能要求或增強,一個看似簡單的元件可能會迅速演變成一個複雜的、相互交織的狀態和 UI 邏輯的混合體。

想像一下建立一個簡單的下拉清單。最初,它看起來很簡單——你管理開啟/關閉狀態並設計它的外觀。但是,隨著你的應用程式成長和演進,這個下拉清單的要求也隨之增加

這些考量中的每一個都為我們的下拉清單元件增加了複雜性。混合狀態、邏輯和 UI 呈現使其更難於維護並限制了它的可重複使用性。它們變得越交織,在沒有意外副作用的情況下進行更改就越困難。

介紹無頭元件模式

面對這些挑戰,無頭元件模式提供了一種解決方法。它強調計算與 UI 表示的分離,賦予開發人員建立多功能、可維護和可重複使用元件的能力。

無頭元件是 React 中的一種設計模式,其中一個元件(通常實作為 React 勾子)僅負責邏輯和狀態管理,而不規定任何特定的 UI(使用者介面)。它提供了運算的「大腦」,但將「外觀」留給實作它的開發人員。從本質上來說,它提供了功能,而不強制特定的視覺表示。

在視覺化時,無頭元件顯示為一個纖細的層,一側與 JSX 檢視介面,另一側在需要時與基礎資料模型通訊。此模式特別有利於僅尋求 UI 行為或狀態管理方面的個人,因為它方便地將這些與視覺表示分開。

圖 1:無頭元件模式

例如,考慮一個無頭下拉清單元件。它將處理開啟/關閉狀態、項目選擇、鍵盤導覽等的狀態管理。當需要渲染時,它不會渲染自己的硬編碼下拉清單 UI,而是將此狀態和邏輯提供給子函式或元件,讓開發人員決定它的視覺外觀。

在本文中,我們將深入探討一個實務範例,從頭開始建構一個複雜的元件——下拉式清單。隨著我們為元件新增更多功能,我們將觀察到所產生的挑戰。透過這一點,我們將展示 Headless Component 模式如何解決這些挑戰、區隔不同的考量,並協助我們製作更多元化的元件。

實作下拉式清單

下拉式清單是在許多地方使用的常見元件。雖然有原生的選取元件可供基本使用案例使用,但提供對每個選項更佳控制權的更進階版本可提供更好的使用者體驗。

從頭開始建立一個完整的實作,需要比乍看之下更多的努力。至關重要的是要考慮鍵盤導覽、無障礙性(例如,螢幕閱讀器相容性)以及在行動裝置上的可用性等等。

我們將從一個簡單的桌面版本開始,它僅支援滑鼠點擊,並逐漸建構更多功能以使其更貼近實際。請注意,此處的目標是揭露一些軟體設計模式,而不是教導如何建構下拉式清單以供實際使用——實際上,我不建議從頭開始執行此操作,而建議改用更成熟的函式庫。

基本上,我們需要一個元素(我們稱之為觸發器)供使用者點擊,以及一個狀態來控制清單面板的顯示和隱藏動作。最初,我們隱藏面板,當觸發器被點擊時,我們顯示清單面板。

import { useState } from "react";

interface Item {
  icon: string;
  text: string;
  description: string;
}

type DropdownProps = {
  items: Item[];
};

const Dropdown = ({ items }: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);

  return (
    <div className="dropdown">
      <div className="trigger" tabIndex={0} onClick={() => setIsOpen(!isOpen)}>
        <span className="selection">
          {selectedItem ? selectedItem.text : "Select an item..."}
        </span>
      </div>
      {isOpen && (
        <div className="dropdown-menu">
          {items.map((item, index) => (
            <div
              key={index}
              onClick={() => setSelectedItem(item)}
              className="item-container"
            >
              <img src={item.icon} alt={item.text} />
              <div className="details">
                <div>{item.text}</div>
                <small>{item.description}</small>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
};

在上面的程式碼中,我們為下拉式清單元件設定了基本結構。使用 useState 鉤子,我們管理 isOpenselectedItem 狀態以控制下拉式清單的行為。點擊觸發器會切換下拉式選單,而選取項目會更新 selectedItem 狀態。

讓我們將元件分解成更小、更易於管理的部分,以更清楚地了解它。此分解並非 Headless Component 模式的一部分,但將複雜的 UI 元件分解成各個部分是一項有價值的活動。

我們可以從萃取一個 Trigger 元件開始,以處理使用者的點擊

const Trigger = ({
  label,
  onClick,
}: {
  label: string;
  onClick: () => void;
}) => {
  return (
    <div className="trigger" tabIndex={0} onClick={onClick}>
      <span className="selection">{label}</span>
    </div>
  );
};

Trigger 元件是一個基本的可點擊 UI 元素,採用要顯示的 labelonClick 處理常式。它與其周圍的環境保持不可知。類似地,我們可以萃取一個 DropdownMenu 元件來呈現項目清單

const DropdownMenu = ({
  items,
  onItemClick,
}: {
  items: Item[];
  onItemClick: (item: Item) => void;
}) => {
  return (
    <div className="dropdown-menu">
      {items.map((item, index) => (
        <div
          key={index}
          onClick={() => onItemClick(item)}
          className="item-container"
        >
          <img src={item.icon} alt={item.text} />
          <div className="details">
            <div>{item.text}</div>
            <small>{item.description}</small>
          </div>
        </div>
      ))}
    </div>
  );
};

DropdownMenu 元件顯示一個項目清單,每個項目都有一個圖示和一個說明。當項目被點擊時,它會觸發提供的 onItemClick 函式,並將選取的項目作為其引數。

然後在 Dropdown 元件中,我們加入 TriggerDropdownMenu,並提供必要的狀態。這種方法可確保 TriggerDropdownMenu 元件保持與狀態無關,並純粹對傳遞的道具做出反應。

const Dropdown = ({ items }: DropdownProps) => {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedItem, setSelectedItem] = useState<Item | null>(null);

  return (
    <div className="dropdown">
      <Trigger
        label={selectedItem ? selectedItem.text : "Select an item..."}
        onClick={() => setIsOpen(!isOpen)}
      />
      {isOpen && <DropdownMenu items={items} onItemClick={setSelectedItem} />}
    </div>
  );
};

在此更新的程式碼結構中,我們透過為下拉式清單的不同部分建立專門的元件來區分考量,使程式碼更井然有序且更容易管理。

圖 3:清單原生實作

如上圖所示,您可以按一下「選擇項目...」觸發器以開啟下拉式選單。從清單中選取值會更新顯示的值,然後關閉下拉式選單。

在這個階段,我們重構的程式碼很清楚,每個區段都很直接且易於調整。修改或引入不同的 Trigger 元件會相對簡單。但是,隨著我們引入更多功能和管理更多狀態,我們目前的元件是否能撐得住?

讓我們透過一個嚴肅的下拉式清單的重要增強功能來找出答案:鍵盤導覽。

實作鍵盤導覽

在我們的下拉式清單中加入鍵盤導覽,透過提供滑鼠互動的替代方案,增強了使用者體驗。這對於無障礙性特別重要,並在網頁上提供順暢的導覽體驗。讓我們探討如何使用 onKeyDown 事件處理常式來達成這個目標。

最初,我們會將 handleKeyDown 函式附加到我們 Dropdown 元件中的 onKeyDown 事件。在此,我們使用 switch 陳述式來判斷按下的特定按鍵,並據此執行動作。例如,當按下「Enter」或「Space」按鍵時,下拉式選單會切換。類似地,「ArrowDown」和「ArrowUp」按鍵允許在清單項目中導覽,必要時會循環回到清單的開始或結束。

const Dropdown = ({ items }: DropdownProps) => {
  // ... previous state variables ...
  const [selectedIndex, setSelectedIndex] = useState<number>(-1);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      // ... case blocks ...
      // ... handling Enter, Space, ArrowDown and ArrowUp ...
    }
  };

  return (
    <div className="dropdown" onKeyDown={handleKeyDown}>
      {/* ... rest of the JSX ... */}
    </div>
  );
};

此外,我們已更新 DropdownMenu 元件以接受 selectedIndex prop。這個 prop 用於套用突出的 CSS 樣式,並將 aria-selected 屬性設定為目前選取的項目,增強視覺回饋和無障礙性。

const DropdownMenu = ({
  items,
  selectedIndex,
  onItemClick,
}: {
  items: Item[];
  selectedIndex: number;
  onItemClick: (item: Item) => void;
}) => {
  return (
    <div className="dropdown-menu" role="listbox">
      {/* ... rest of the JSX ... */}
    </div>
  );
};

現在,我們的 Dropdown 元件與狀態管理程式碼和呈現邏輯都糾纏在一起。它包含一個廣泛的 switch case,以及所有狀態管理建構,例如 `selectedItem`, `selectedIndex`, `setSelectedItem` 等。

使用自訂 Hook 實作無頭元件

為了解決這個問題,我們將透過一個名為 useDropdown 的自訂勾子來引入無頭元件的概念。這個勾子有效地封裝了狀態和鍵盤事件處理邏輯,傳回一個填滿必要狀態和函式的物件。透過在我們的 Dropdown 元件中解構這個物件,我們可以讓我們的程式碼保持簡潔且可持續。

魔術就在於 useDropdown 鉤子,我們的主角,無頭元件。這個多功能單元包含下拉式選單所需的一切:是否開啟、選取的項目、突出的項目、對 Enter 鍵的反應,等等。它的美妙之處在於它的適應性;你可以將它與各種視覺呈現配對,也就是你的 JSX 元素。

const useDropdown = (items: Item[]) => {
  // ... state variables ...

  // helper function can return some aria attribute for UI
  const getAriaAttributes = () => ({
    role: "combobox",
    "aria-expanded": isOpen,
    "aria-activedescendant": selectedItem ? selectedItem.text : undefined,
  });

  const handleKeyDown = (e: React.KeyboardEvent) => {
    // ... switch statement ...
  };
  
  const toggleDropdown = () => setIsOpen((isOpen) => !isOpen);

  return {
    isOpen,
    toggleDropdown,
    handleKeyDown,
    selectedItem,
    setSelectedItem,
    selectedIndex,
  };
};

現在,我們的 Dropdown 元件簡化了、更短且更容易理解。它利用 useDropdown 鉤子來管理它的狀態並處理鍵盤互動,展示了明確的關注點分離,讓程式碼更容易理解和管理。

const Dropdown = ({ items }: DropdownProps) => {
  const {
    isOpen,
    selectedItem,
    selectedIndex,
    toggleDropdown,
    handleKeyDown,
    setSelectedItem,
  } = useDropdown(items);

  return (
    <div className="dropdown" onKeyDown={handleKeyDown}>
      <Trigger
        onClick={toggleDropdown}
        label={selectedItem ? selectedItem.text : "Select an item..."}
      />
      {isOpen && (
        <DropdownMenu
          items={items}
          onItemClick={setSelectedItem}
          selectedIndex={selectedIndex}
        />
      )}
    </div>
  );
};

透過這些修改,我們成功地在下拉式清單中實作了鍵盤導覽,讓它更易於存取且更友善。這個範例也說明了如何利用鉤子以結構化且模組化的方式來管理複雜的狀態和邏輯,為我們的 UI 元件鋪路,以進行進一步的強化和功能新增。

這個設計的美妙之處在於它將邏輯與呈現明確地分開。所謂的「邏輯」,我們指的是 select 元件的核心功能:開啟/關閉狀態、選取的項目、突出的元素,以及對使用者輸入的反應,例如在從清單中選擇時按下向下箭頭。這個區分確保我們的元件保留它的核心行為,而不會受到特定視覺呈現的約束,證明了「無頭元件」這個術語的合理性。

測試無頭元件

我們的元件邏輯是集中化的,讓它可以在不同的情境中重複使用。這個功能的可靠性至關重要。因此,全面的測試變得勢在必行。好消息是,測試這種行為很簡單。

我們可以透過呼叫公開方法並觀察對應的狀態變更來評估狀態管理。例如,我們可以檢查 toggleDropdownisOpen 狀態之間的關係。

const items = [{ text: "Apple" }, { text: "Orange" }, { text: "Banana" }];

it("should handle dropdown open/close state", () => {
  const { result } = renderHook(() => useDropdown(items));

  expect(result.current.isOpen).toBe(false);

  act(() => {
    result.current.toggleDropdown();
  });

  expect(result.current.isOpen).toBe(true);

  act(() => {
    result.current.toggleDropdown();
  });

  expect(result.current.isOpen).toBe(false);
});

鍵盤導覽測試稍微複雜一些,主要是因為沒有視覺介面。這需要更整合的測試方法。一個有效的方法是製作一個假的測試元件來驗證行為。此類測試具有雙重目的:它們提供了一個關於如何使用無頭元件的教學指南,而且由於它們使用 JSX,因此提供了對使用者互動的真實見解。

考慮以下測試,它用整合測試取代了先前的狀態檢查

it("trigger to toggle", async () => {
  render(<SimpleDropdown />);

  const trigger = screen.getByRole("button");

  expect(trigger).toBeInTheDocument();

  await userEvent.click(trigger);

  const list = screen.getByRole("listbox");
  expect(list).toBeInTheDocument();

  await userEvent.click(trigger);

  expect(list).not.toBeInTheDocument();
});

下面的 SimpleDropdown 是個假的 [1] 元件,專門用於測試。它也兼作一個動手實作範例,供使用者在實作無頭元件時參考。

const SimpleDropdown = () => {
  const {
    isOpen,
    toggleDropdown,
    selectedIndex,
    selectedItem,
    updateSelectedItem,
    getAriaAttributes,
    dropdownRef,
  } = useDropdown(items);

  return (
    <div
      tabIndex={0}
      ref={dropdownRef}
      {...getAriaAttributes()}
    >
      <button onClick={toggleDropdown}>Select</button>
      <p data-testid="selected-item">{selectedItem?.text}</p>
      {isOpen && (
        <ul role="listbox">
          {items.map((item, index) => (
            <li
              key={index}
              role="option"
              aria-selected={index === selectedIndex}
              onClick={() => updateSelectedItem(item)}
            >
              {item.text}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

SimpleDropdown 是一個為測試而設計的虛擬元件。它使用 useDropdown 的集中邏輯來建立下拉清單。當「選擇」按鈕被按一下時,清單就會出現或消失。此清單包含一組項目(蘋果、橘子、香蕉),使用者可以按一下項目來選擇任何項目。上述測試確保此行為按預期運作。

有了 SimpleDropdown 元件,我們就能測試更複雜但更實際的場景。

it("select item using keyboard navigation", async () => {
  render(<SimpleDropdown />);

  const trigger = screen.getByRole("button");

  expect(trigger).toBeInTheDocument();

  await userEvent.click(trigger);

  const dropdown = screen.getByRole("combobox");
  dropdown.focus();

  await userEvent.type(dropdown, "{arrowdown}");
  await userEvent.type(dropdown, "{enter}");

  await expect(screen.getByTestId("selected-item")).toHaveTextContent(
    items[0].text
  );
});

測試確保使用者可以使用鍵盤輸入從下拉式選單中選擇項目。在呈現 SimpleDropdown 並按一下其觸發按鈕後,下拉式選單會獲得焦點。隨後,測試模擬鍵盤向下箭頭按鍵來導覽至第一個項目,並按一下 Enter 鍵來選擇它。然後,測試驗證所選項目是否顯示預期的文字。

雖然將自訂勾子用於無頭元件很常見,但這並非唯一的方法。事實上,在勾子出現之前,開發人員使用渲染道具或高階元件來實作無頭元件。如今,即使高階元件已失去部分過去的熱門程度,採用 React 背景的宣告式 API 仍然相當受到青睞。

使用 context API 的宣告式無頭元件

我將展示另一種宣告式方法,以達成類似的結果,在此範例中採用 React 背景 API。透過在元件樹中建立階層,並讓每個元件都可以替換,我們可以提供有價值的介面給使用者,此介面不僅能有效運作(支援鍵盤導覽、無障礙功能等),還能提供自訂其元件的彈性。

import { HeadlessDropdown as Dropdown } from "./HeadlessDropdown";

const HeadlessDropdownUsage = ({ items }: { items: Item[] }) => {
  return (
    <Dropdown items={items}>
      <Dropdown.Trigger as={Trigger}>Select an option</Dropdown.Trigger>
      <Dropdown.List as={CustomList}>
        {items.map((item, index) => (
          <Dropdown.Option
            index={index}
            key={index}
            item={item}
            as={CustomListItem}
          />
        ))}
      </Dropdown.List>
    </Dropdown>
  );
};

HeadlessDropdownUsage 元件接受 items 道具,其類型為 Item 陣列,並傳回 Dropdown 元件。在 Dropdown 內部,它定義一個 Dropdown.Trigger 來呈現 CustomTrigger 元件,一個 Dropdown.List 來呈現 CustomList 元件,並對 items 陣列進行對應,為每個項目建立一個 Dropdown.Option,呈現 CustomListItem 元件。

此結構提供一種彈性且宣告式的自訂下拉式選單呈現和行為方式,同時保持元件之間明確的階層關係。請注意,Dropdown.TriggerDropdown.ListDropdown.Option 元件提供未設定樣式的預設 HTML 元素(分別為按鈕、ul 和 li)。它們各接受一個 as 道具,讓使用者可以使用自己的樣式和行為自訂元件。

例如,我們可以定義這些自訂元件,並如上所述使用它。

const CustomTrigger = ({ onClick, ...props }) => (
  <button className="trigger" onClick={onClick} {...props} />
);

const CustomList = ({ ...props }) => (
  <div {...props} className="dropdown-menu" />
);

const CustomListItem = ({ ...props }) => (
  <div {...props} className="item-container" />
);

圖 4:具有自訂元素的宣告式使用者介面

實作並不複雜。我們可以簡單地在 Dropdown(根元素)中定義一個 context,並將所有需要管理的狀態放入其中,然後在子節點中使用該 context,以便它們可以存取狀態(或透過 context 中的 API 變更這些狀態)。

type DropdownContextType<T> = {
  isOpen: boolean;
  toggleDropdown: () => void;
  selectedIndex: number;
  selectedItem: T | null;
  updateSelectedItem: (item: T) => void;
  getAriaAttributes: () => any;
  dropdownRef: RefObject<HTMLElement>;
};

function createDropdownContext<T>() {
  return createContext<DropdownContextType<T> | null>(null);
}

const DropdownContext = createDropdownContext();

export const useDropdownContext = () => {
  const context = useContext(DropdownContext);
  if (!context) {
    throw new Error("Components must be used within a <Dropdown/>");
  }
  return context;
};

程式碼定義了一個泛型的 DropdownContextType 類型,以及一個 createDropdownContext 函式來建立具有此類型的 context。DropdownContext 是使用此函式建立的。useDropdownContext 是自訂勾子,用於存取此 context,如果在 <Dropdown/> 元件外部使用它,則會擲回錯誤,確保在所需的元件階層中正確使用。

然後,我們可以定義使用 context 的元件。我們可以從 context 提供者開始

const HeadlessDropdown = <T extends { text: string }>({
  children,
  items,
}: {
  children: React.ReactNode;
  items: T[];
}) => {
  const {
    //... all the states and state setters from the hook
  } = useDropdown(items);

  return (
    <DropdownContext.Provider
      value={{
        isOpen,
        toggleDropdown,
        selectedIndex,
        selectedItem,
        updateSelectedItem,
      }}
    >
      <div
        ref={dropdownRef as RefObject<HTMLDivElement>}
        {...getAriaAttributes()}
      >
        {children}
      </div>
    </DropdownContext.Provider>
  );
};

HeadlessDropdown 元件採用兩個道具:childrenitems,並使用自訂勾子 useDropdown 來管理其狀態和行為。它透過 DropdownContext.Provider 提供 context,以與其後代共用狀態和行為。在 div 中,它設定 ref 並套用 ARIA 屬性以利於存取,然後呈現其 children 以顯示巢狀元件,並啟用結構化且可自訂的 dropdown 功能。

請注意我們如何使用在前一節中定義的 useDropdown 勾子,然後將這些值傳遞給 HeadlessDropdown 的子代。接著,我們可以定義子元件

HeadlessDropdown.Trigger = function Trigger({
  as: Component = "button",
  ...props
}) {
  const { toggleDropdown } = useDropdownContext();

  return <Component tabIndex={0} onClick={toggleDropdown} {...props} />;
};

HeadlessDropdown.List = function List({
  as: Component = "ul",
  ...props
}) {
  const { isOpen } = useDropdownContext();

  return isOpen ? <Component {...props} role="listbox" tabIndex={0} /> : null;
};

HeadlessDropdown.Option = function Option({
  as: Component = "li",
  index,
  item,
  ...props
}) {
  const { updateSelectedItem, selectedIndex } = useDropdownContext();

  return (
    <Component
      role="option"
      aria-selected={index === selectedIndex}
      key={index}
      onClick={() => updateSelectedItem(item)}
      {...props}
    >
      {item.text}
    </Component>
  );
};

我們定義了一個類型 GenericComponentType 來處理元件或 HTML 標籤以及任何其他屬性。三個函式 HeadlessDropdown.TriggerHeadlessDropdown.ListHeadlessDropdown.Option 被定義為用於呈現下拉式選單的不同部分。每個函式都使用 as 道具來允許自訂元件的呈現,並將其他屬性散佈到呈現的元件上。它們都透過 useDropdownContext 存取共用狀態和行為。

  • HeadlessDropdown.Trigger 預設呈現一個按鈕,用於切換下拉式選單。
  • HeadlessDropdown.List 如果下拉式選單是開啟的,則會呈現一個清單容器。
  • HeadlessDropdown.Option 呈現個別的清單項目,並在按一下時更新所選項目。

這些函式共同允許自訂且可存取的下拉式選單結構。

這在很大程度上取決於使用者如何選擇在他們的程式碼庫中使用 Headless 元件。就個人而言,我傾向於使用勾子,因為它們不涉及任何 DOM(或虛擬 DOM)互動;共用狀態邏輯和 UI 之間唯一的橋樑是 ref 物件。另一方面,使用基於 context 的實作,當使用者決定不自訂它時,將會提供預設實作。

在即將到來的範例中,我將展示如何使用 useDropdown hook 輕鬆地轉換到不同的 UI,同時保留核心功能。

適應新的 UI 需求

考慮一個場景,其中新的設計需要使用按鈕作為觸發器,並在下拉式清單中的文字旁邊顯示頭像。由於邏輯已經封裝在我們的 useDropdown hook 中,因此適應這個新的 UI 非常簡單。

在下方新的 DropdownTailwind 元件中,我們使用了 Tailwind CSS(Tailwind CSS 是實用優先的 CSS 框架,用於快速建立自訂使用者介面)來設定我們的元素樣式。結構略有修改 - 按鈕用作觸發器,下拉式清單中的每個項目現在都包含一個影像。儘管有這些 UI 變更,但核心功能仍然完好無缺,這要歸功於我們的 useDropdown hook。

const DropdownTailwind = ({ items }: DropdownProps) => {
  const {
    isOpen,
    selectedItem,
    selectedIndex,
    toggleDropdown,
    handleKeyDown,
    setSelectedItem,
  } = useDropdown<Item>(items);

  return (
    <div
      className="relative"
      onClick={toggleDropdown}
      onKeyDown={handleKeyDown}
    >
      <button className="btn p-2 border ..." tabIndex={0}>
        {selectedItem ? selectedItem.text : "Select an item..."}
      </button>

      {isOpen && (
        <ul
          className="dropdown-menu ..."
          role="listbox"
        >
          {(items).map((item, index) => (
            <li
              key={index}
              role="option"
            >
            {/* ... rest of the JSX ... */}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

在此版本中,DropdownTailwind 元件與 useDropdown hook 介接以管理其狀態和互動。此設計確保任何 UI 修改或增強都不需要重新實作基礎邏輯,大幅簡化了對新設計需求的適應。

我們也可以使用 React Devtools 更清楚地視覺化程式碼,請注意在 hooks 區段中,所有狀態都列在其中

圖 5:Devtools

每個下拉式清單,無論其外部外觀如何,在內部都共用一致的行為,所有這些都封裝在 useDropdown hook(無頭元件)中。但是,如果我們需要管理更多狀態,例如,當我們必須從遠端擷取資料時的非同步狀態,該怎麼辦?

使用額外狀態深入探討

隨著我們下拉式元件的進展,讓我們探討在處理遠端資料時發揮作用的更複雜狀態。從遠端來源擷取資料的場景帶來了管理更多狀態的必要性 - 具體來說,我們需要處理載入、錯誤和資料狀態。

揭開遠端資料擷取

若要從遠端伺服器載入資料,我們需要定義三個新狀態:loadingerrordata。以下是我們如何使用 useEffect 呼叫通常進行此操作的方法

//...
  const [loading, setLoading] = useState<boolean>(false);
  const [data, setData] = useState<Item[] | null>(null);
  const [error, setError] = useState<Error | undefined>(undefined);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);

      try {
        const response = await fetch("/api/users");

        if (!response.ok) {
          const error = await response.json();
          throw new Error(`Error: ${error.error || response.status}`);
        }

        const data = await response.json();
        setData(data);
      } catch (e) {
        setError(e as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, []);

//...

程式碼初始化三個狀態變數:loadingdataerror。當元件掛載時,它會觸發非同步函式以從「/api/users」端點擷取資料。它在擷取之前將 loading 設定為 true,並在之後將其設定為 false。如果成功擷取資料,則將其儲存在 data 狀態中。如果發生錯誤,則會擷取並儲存在 error 狀態中。

重構以追求優雅和可重複使用

將擷取邏輯直接整合在元件中可行,但並非最優雅或可重複使用的做法。我們可以進一步推動無頭元件背後的原則,將邏輯和狀態從 UI 中分離出來。讓我們透過將擷取邏輯萃取到一個獨立函式中來重構它

const fetchUsers = async () => {
  const response = await fetch("/api/users");

  if (!response.ok) {
    const error = await response.json();
    throw new Error('Something went wrong');
  }

  return await response.json();
};

現在有了 fetchUsers 函式,我們可以進一步抽象我們的擷取邏輯到一個通用勾子中。這個勾子會接受一個擷取函式,並管理相關的載入、錯誤和資料狀態

const useService = <T>(fetch: () => Promise<T>) => {
  const [loading, setLoading] = useState<boolean>(false);
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<Error | undefined>(undefined);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);

      try {
        const data = await fetch();
        setData(data);
      } catch(e) {
        setError(e as Error);
      } finally {
        setLoading(false);
      }
    };

    fetchData();
  }, [fetch]);

  return {
    loading,
    error,
    data,
  };
}

現在,useService 勾子成為我們應用程式中資料擷取的可重複使用解決方案。它是一個精簡的抽象,我們可以用來擷取各種資料類型,如下所示

// fetch products
const { loading, error, data } = useService(fetchProducts);
// or other type of resources
const { loading, error, data } = useService(fetchTickets);

透過這個重構,我們不僅簡化了我們的資料擷取邏輯,也讓它可以在我們應用程式的不同場景中重複使用。當我們持續強化我們的下拉式選單元件,並深入探討更進階的功能和最佳化時,這奠定了穩固的基礎。

維持下拉式元件的簡潔性

多虧了 useServiceuseDropdown 勾子中抽象的邏輯,整合遠端資料擷取並未使我們的 Dropdown 元件變得複雜。我們的元件程式碼維持在最簡潔的形式,有效管理擷取狀態並根據接收到的資料呈現內容。

const Dropdown = () => {
  const { data, loading, error } = useService(fetchUsers);

  const {
    toggleDropdown,
    dropdownRef,
    isOpen,
    selectedItem,
    selectedIndex,
    updateSelectedItem,
    getAriaAttributes,
  } = useDropdown<Item>(data || []);

  const renderContent = () => {
    if (loading) return <Loading />;
    if (error) return <Error />;
    if (data) {
      return (
        <DropdownMenu
          items={data}
          updateSelectedItem={updateSelectedItem}
          selectedIndex={selectedIndex}
        />
      );
    }
    return null;
  };

  return (
    <div
      className="dropdown"
      ref={dropdownRef as RefObject<HTMLDivElement>}
      {...getAriaAttributes()}
    >
      <Trigger
        onClick={toggleDropdown}
        text={selectedItem ? selectedItem.text : "Select an item..."}
      />
      {isOpen && renderContent()}
    </div>
  );
};

在這個更新的 Dropdown 元件中,我們利用 useService 勾子來管理資料擷取狀態,並利用 useDropdown 勾子來管理下拉式選單特定的狀態和互動。renderContent 函式優雅地根據擷取狀態處理呈現邏輯,確保無論是載入、錯誤或資料,都能顯示正確的內容。

在上面的範例中,觀察無頭元件如何促進各部分之間的鬆散耦合。這個彈性讓我們可以交換各部分以獲得不同的組合。透過共用的 LoadingError 元件,我們可以輕鬆製作一個具有預設 JSX 和樣式的 UserDropdown,或是一個使用 TailwindCSS 並從不同的 API 端點擷取資料的 ProductDropdown

總結無頭元件模式

無頭元件模式揭示了一條強健的途徑,用於將我們的 JSX 程式碼從底層邏輯中乾淨地分離出來。雖然使用 JSX 組合宣告式 UI 是很自然的,但真正的挑戰在於管理狀態。這正是無頭元件發揮作用的地方,它承擔了所有狀態管理的複雜性,推動我們走向新的抽象層次。

實質上,無頭元件是一個函式或物件,它封裝了邏輯,但本身不呈現任何東西。它將呈現部分留給使用者,從而提供在呈現 UI 時高度的彈性。當我們有複雜的邏輯,希望在不同的視覺表示中重複使用時,此模式會非常有用。

function useDropdownLogic() {
  // ... all the dropdown logic
  return {
    // ... exposed logic
  };
}

function MyDropdown() {
  const dropdownLogic = useDropdownLogic();
  return (
    // ... render the UI using the logic from dropdownLogic
  );
}

無頭元件提供了多項好處,包括增強的可重複使用性,因為它們封裝了可以在多個元件中共享的邏輯,遵循 DRY(不要重複自己)原則。它們透過明確區分邏輯和呈現來強調明確的分工,這是建立可維護程式碼的基本實務。此外,它們透過允許開發人員使用相同的核心邏輯採用不同的 UI 實作來提供彈性,這在處理不同的設計需求或使用各種架構時特別有利。

然而,必須明智地使用它們。就像任何設計模式一樣,它們也伴隨著挑戰。對於不熟悉的人來說,可能會有最初的學習曲線,可能會暫時減緩開發速度。此外,如果沒有明智地應用,無頭元件引入的抽象可能會增加間接層級,可能會使程式碼的可讀性複雜化。

我想指出,此模式可以在其他前端程式庫或架構中應用。例如,Vue 將此概念稱為renderless元件。它體現了相同的原則,促使開發人員將邏輯和狀態管理隔離到一個不同的元件中,從而使用戶能夠在它周圍建構 UI。

我不確定它在 Angular 或其他架構中的實作或相容性,但我建議考慮其在特定環境中的潛在好處。

重新探討 GUI 中的根模式

如果您在業界待的時間夠長,或者有在桌面設定中使用 GUI 應用程式的經驗,您可能會對無頭元件模式有一些熟悉感,也許是用不同的名稱,例如 MVVM 中的檢視模型、表示模型,或其他術語,具體取決於您的接觸經驗。馬丁·福勒在幾年前的綜合文章中深入探討了這些術語,他在其中釐清了 GUI 世界中廣泛使用的許多術語,例如 MVC、Model-View-Presenter 等。

表示模型將檢視的狀態和行為抽象到表示層中的模型類別中。此模型與網域層協調,並提供一個介面給檢視,將檢視中的決策制定減到最低...

-- 馬丁·福勒

儘管如此,我相信有必要對此既定的模式進行一些擴充,並探討它如何在 React 或前端世界中運作。隨著技術的進步,傳統 GUI 應用程式所面臨的一些挑戰可能不再具有相關性,使某些強制性元素現在變成可選的。

例如,將 UI 和邏輯分開的一個原因在於測試其組合的難度,特別是在無頭CI/CD環境中。因此,我們的目標是盡可能將其提取到無 UI 代碼中,以簡化測試過程。不過,這在 React 和許多其他 Web 框架中並不是一個重要問題。一方面,我們有強大的內存測試機制,例如jsdom,用於測試 UI 行為、DOM 操作等。這些測試可以在任何環境中運行,例如在無頭 CI/CD 伺服器上,我們可以使用Cypress在內存瀏覽器(例如無頭 Chrome)中輕鬆執行真實瀏覽器測試,而這在構思 MVC/MVP 時對於桌面應用程式來說是不可行的。

MVC 面臨的另一個重大挑戰是資料同步,這需要簡報者或簡報模型來編排基礎資料的變更,並通知其他呈現部分。以下說明了經典範例

圖 7:一個模型有多個簡報

在上面的說明中,三個 UI 元件(表格、折線圖和熱力圖)完全獨立,但它們都呈現相同的模型資料。當您從表格中修改資料時,其他兩個圖表將會重新整理。為了能夠偵測變更,並套用變更以相應地重新整理元件,您需要手動設定事件監聽器。

不過,隨著單向資料流的出現,React(以及許多其他現代框架)已經開闢了一條不同的道路。作為開發人員,我們不再需要監控模型變更。基本概念是將每個變更視為一個全新的實例,並從頭開始重新呈現所有內容 - 這裡我要特別說明,我大幅簡化了整個流程,忽略了虛擬 DOM以及區分和調和流程 - 這表示在程式碼庫中,已經消除了在模型變更後註冊事件監聽器以準確更新其他區段的要求。

總之,無頭元件並非旨在重新發明既定的 UI 模式;相反,它作為元件式 UI 架構中的實作。將邏輯和狀態管理與檢視分開的原則仍然很重要,特別是在劃分明確的責任和有機會將一個檢視替換為另一個檢視的情況下。

了解社群

無頭元件的概念並非新穎,它已經存在一段時間,但尚未被廣泛承認或納入專案中。然而,多個函式庫採用了無頭元件模式,推廣了無障礙、可調整和可重複使用的元件開發。其中一些函式庫已經在社群中獲得顯著的關注

  • React ARIA:Adobe 的函式庫,提供無障礙基本元件和掛勾,用於建置包容性的 React 應用程式。它提供一系列掛勾,用於管理鍵盤互動、焦點管理和 ARIA 註解,讓建立無障礙使用者介面元件變得更容易。
  • Headless UI:完全無樣式、完全無障礙的使用者介面元件函式庫,設計為與 Tailwind CSS 完美整合。它提供行為和無障礙基礎,讓你可以建置自己的樣式化元件。
  • React Table:用於建置快速且可擴充表格和資料格線的 React 無頭實用程式。它提供一個靈活的掛勾,讓你可以輕鬆建立複雜的表格,讓使用者介面呈現交由你決定。
  • Downshift:一個極簡主義函式庫,協助你建立無障礙且可自訂的下拉式選單、組合方塊等。它處理所有邏輯,同時讓你定義呈現層面。

這些函式庫透過封裝複雜的邏輯和行為,體現了無頭元件模式的精髓,讓建立高度互動且無障礙的使用者介面元件變得簡單。雖然提供的範例可用作學習的踏腳石,但在實際情況中,明智的做法是利用這些可供生產使用的函式庫,來建置強健、無障礙且可自訂的元件。

此模式不僅教導我們如何管理複雜的邏輯和狀態,還促使我們探索已經磨練無頭元件方法的、可供生產使用的函式庫,以提供強健、無障礙且可自訂的元件,供實際使用。

摘要

在本文中,我們深入探討無頭元件的概念,這是一個在建置可重複使用的使用者介面邏輯時有時會被忽略的模式。我們以建立一個複雜的下拉式清單為例,從一個簡單的下拉式選單開始,逐步引入鍵盤導覽和非同步資料擷取等功能。此方法展示了將可重複使用的邏輯無縫提取到無頭元件中,並強調了我們可以輕鬆覆蓋新的使用者介面的方式。

透過實際範例,我們闡明了這種分離如何為建置可重複使用、無障礙且客製化的元件鋪路。我們也聚焦於著名的函式庫,例如 React Table、Downshift、React UseGesture、React ARIA 和 Headless UI,這些函式庫支持無頭元件模式。這些函式庫提供預先組態的解決方案,用於開發互動且使用者友善的使用者介面元件。

此深入探討強調了在使用者介面開發過程中區分問題的重要性,並強調其在建置可擴充、無障礙且可維護的 React 應用程式中的重要性。


致謝

感謝我的楷模馬丁·福勒,他在所有技術細節上指導我,並使我得以在這個網站上發表文章。

腳註

1: 假物件是一個封裝對外部系統或資源存取的物件。當你不想將所有採用邏輯分散到你的程式碼庫中時,它很有用,而且當外部系統變更時,在一個地方變更會更容易。

重大修訂

2023 年 11 月 7 日:發布最終版本

2023 年 11 月 1 日:發布第一版本