无头组件:一种用于组合 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,而是将此状态和逻辑提供给子函数或组件,让开发人员决定它应该如何以视觉方式显示。

在本文中,我们将深入研究一个实际示例,从头开始构建一个复杂的组件 - 下拉列表。当我们向组件添加更多功能时,我们将观察出现的挑战。通过这样做,我们将演示无头组件模式如何解决这些挑战,将不同的关注点隔离开来,并帮助我们创建更灵活的组件。

实现下拉列表

下拉列表是许多地方使用的常见组件。虽然对于基本用例有一个本机选择组件,但一个更高级的版本提供了对每个选项的更多控制,从而提供更好的用户体验。

从头开始创建一个完整的实现,比乍一看需要更多的努力。必须考虑键盘导航、无障碍性(例如,屏幕阅读器兼容性)和移动设备上的可用性等因素。

我们将从一个简单的桌面版本开始,该版本只支持鼠标点击,并逐步添加更多功能使其更逼真。请注意,这里的目标是揭示一些软件设计模式,而不是教授如何为生产使用构建下拉列表 - 实际上,我不建议从头开始这样做,而是建议使用更成熟的库。

基本上,我们需要一个元素(我们称之为触发器)供用户点击,以及一个状态来控制列表面板的显示和隐藏操作。最初,我们隐藏面板,当点击触发器时,我们显示列表面板。

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状态。

让我们将组件分解成更小、更易于管理的部分,以便更清楚地看到它。这种分解不是无头组件模式的一部分,但将复杂的 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 元素,它接收一个要显示的label和一个onClick处理程序。它对周围的环境保持不可知。类似地,我们可以提取一个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道具。此道具用于将突出显示的 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 以及所有状态管理结构,例如selectedItemselectedIndexsetSelectedItem等等。

使用自定义钩子实现无头组件

为了解决这个问题,我们将引入一个无头组件的概念,通过一个名为useDropdown的自定义钩子。此钩子有效地封装了状态和键盘事件处理逻辑,返回一个包含基本状态和函数的对象。通过在我们的Dropdown组件中解构它,我们保持代码整洁和可持续。

魔力在于useDropdown钩子,我们的主角 - 无头组件。这个多功能单元包含下拉菜单所需的一切:它是打开的,选定的项目,突出显示的元素,以及对用户输入(例如,在从列表中选择时按下 ArrowDown)的反应。它的美妙之处在于它的适应性;你可以将它与各种视觉表示(你的 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组件的核心功能:打开/关闭状态、选定的项目、突出显示的元素以及对用户输入(例如,在从列表中选择时按下 ArrowDown)的反应。这种划分确保了我们的组件保留其核心行为,而不受限于特定的视觉表示,从而证明了“无头组件”这一术语的合理性。

测试无头组件

我们组件的逻辑是集中的,使其能够在各种场景中重复使用。对于此功能来说,可靠性至关重要。因此,全面的测试变得势在必行。好消息是,测试这种行为很简单。

我们可以通过调用一个公共方法并观察相应的状态变化来评估状态管理。例如,我们可以检查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并单击其触发按钮后,下拉菜单将获得焦点。随后,测试模拟键盘向下箭头按下以导航到第一个项目,并按下回车键以选择它。然后,测试验证选定项目是否显示预期文本。

虽然在无头组件中使用自定义钩子很常见,但这并不是唯一的方法。事实上,在钩子出现之前,开发人员使用渲染道具或高阶组件来实现无头组件。如今,尽管高阶组件已经失去了一些以前的人气,但使用 React 上下文的声明式 API 仍然相当受欢迎。

使用上下文 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(根元素)中定义一个上下文,并将所有需要管理的状态放在里面,并在子节点中使用该上下文,以便它们可以访问状态(或通过上下文中的 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函数来创建具有此类型的上下文。DropdownContext使用此函数创建。useDropdownContext是一个自定义钩子,它访问此上下文,如果它在<Dropdown/>组件之外使用,则会抛出错误,确保在所需的组件层次结构中正确使用。

然后我们可以定义使用上下文的组件。我们可以从上下文提供者开始

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提供一个上下文,以与其后代共享状态和行为。在一个div中,它设置了一个引用并应用了 ARIA 属性以实现可访问性,然后渲染其children以显示嵌套组件,从而实现结构化且可自定义的下拉功能。

注意我们如何使用我们在上一节中定义的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渲染单个列表项,并在单击时更新选定项。

这些函数共同允许可自定义且可访问的下拉菜单结构。

这在很大程度上取决于用户在代码库中使用无头组件的方式。就我个人而言,我倾向于钩子,因为它们不涉及任何 DOM(或虚拟 DOM)交互;共享状态逻辑和 UI 之间的唯一桥梁是引用对象。另一方面,使用基于上下文的实现,当用户决定不自定义它时,将提供默认实现。

在接下来的示例中,我将演示如何使用useDropdown钩子轻松地过渡到不同的 UI,同时保留核心功能。

适应新的 UI 需求

考虑这样一种情况,新设计需要使用按钮作为触发器,并在下拉列表中显示头像和文本。由于我们的useDropdown钩子已经封装了逻辑,因此适应这种新的 UI 非常简单。

在下面的新DropdownTailwind组件中,我们使用了 Tailwind CSS(Tailwind CSS 是一个实用优先的 CSS 框架,用于快速构建自定义用户界面)来为我们的元素设置样式。结构略有修改 - 按钮用作触发器,下拉列表中的每个项目现在都包含一个图像。尽管有这些 UI 更改,但由于我们的useDropdown钩子,核心功能保持不变。

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钩子交互以管理其状态和交互。这种设计确保任何 UI 修改或增强都不需要重新实现底层逻辑,从而大大简化了对新设计要求的适应。

我们还可以使用 React Devtools 更好地可视化代码,请注意在钩子部分,所有状态都列在其中

图 5:Devtools

每个下拉列表,无论其外部外观如何,在内部都共享一致的行为,所有这些都封装在useDropdown钩子(无头组件)中。但是,如果我们需要管理更多状态,例如,当我们必须从远程获取数据时,异步状态呢?

深入探讨附加状态

随着我们对下拉组件的改进,让我们探索处理远程数据时出现的更复杂的状态。从远程源获取数据的场景带来了管理更多状态的必要性 - 具体来说,我们需要处理加载、错误和数据状态。

揭示远程数据获取

要从远程服务器加载数据,我们需要定义三个新状态: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 中的视图模型、演示模型,还是其他术语,具体取决于您的接触。Martin Fowler 在几年前的一篇综合文章中深入探讨了这些术语,他澄清了许多在 GUI 世界中广泛使用的术语,例如 MVC、模型-视图-演示器等。

演示模型将视图的状态和行为抽象到演示层中的模型类中。该模型与域层协调并为视图提供接口,最大限度地减少视图中的决策制定……

-- Martin Fowler

尽管如此,我认为有必要对这种已建立的模式进行扩展,并探讨它如何在 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 注释,从而更轻松地创建可访问的 UI 组件。
  • Headless UI:一个完全无样式、完全可访问的 UI 组件库,旨在与 Tailwind CSS 完美集成。它提供了行为和可访问性基础,您可以在此基础上构建自己的样式化组件。
  • React Table:一个用于为 React 构建快速且可扩展的表格和数据网格的无头实用程序。它提供了一个灵活的钩子,使您可以轻松创建复杂的表格,并将 UI 表示留给您。
  • Downshift:一个极简的库,可帮助您创建可访问且可定制的下拉菜单、组合框等。它处理所有逻辑,同时让您定义渲染方面。

这些库体现了无头组件模式的本质,它们封装了复杂的逻辑和行为,使创建高度交互式和可访问的 UI 组件变得轻而易举。虽然提供的示例可以作为学习的垫脚石,但在现实场景中构建健壮、可访问和可定制的组件时,最好利用这些生产就绪的库。

这种模式不仅教会我们如何管理复杂的逻辑和状态,而且还促使我们探索已经磨练了无头组件方法以提供健壮、可访问和可定制组件的生产就绪库,以供现实世界使用。

总结

在本文中,我们深入探讨了无头组件的概念,这是一种在构建可重用 UI 逻辑时有时会被忽略的模式。以创建复杂的下拉列表为例,我们从一个简单的下拉列表开始,逐步引入键盘导航和异步数据获取等功能。这种方法展示了将可重用逻辑无缝提取到无头组件中的过程,并突出了我们轻松覆盖新 UI 的便捷性。

通过实际示例,我们阐明了这种分离如何为构建可重用、可访问和定制的组件铺平道路。我们还重点介绍了 React Table、Downshift、React UseGesture、React ARIA 和 Headless UI 等知名库,它们支持无头组件模式。这些库提供了用于开发交互式和用户友好型 UI 组件的预配置解决方案。

这次深入探讨强调了关注点分离在 UI 开发过程中的关键作用,强调了它在构建可扩展、可访问和可维护的 React 应用程序中的重要性。


致谢

感谢我的榜样 Martin Fowler,他指导我完成所有技术细节,并使我能够在本网站上发布这篇文章。

脚注

1: Fake 是一个封装对外部系统或资源访问的对象。当您不想将所有采用逻辑分散到代码库中时,它很有用,并且当外部系统发生更改时,它更容易在一个地方更改。

重大修订

2023 年 11 月 7 日:发布最终部分

2023 年 11 月 1 日:发布第一部分