无头组件:一种构建 React UI 的模式


随着 React UI 控件变得越来越复杂,复杂的逻辑可能与视觉表示交织在一起。这使得很难推理组件的行为,难以对其进行测试,并且需要构建需要不同外观的类似组件。无头组件提取所有非视觉逻辑和状态管理,将组件的大脑与其外观分开。

React 彻底改变了我们对 UI 组件和 UI 状态管理的思考方式。但随着每一个新的功能请求或增强,一个看似简单的组件可以快速演变成​​一个相互交织的状态和 UI 逻辑的复杂融合。

想象一下构建一个简单的下拉列表。最初,它看起来很简单——您管理打开/关闭状态并设计其外观。但是,随着您的应用程序的增长和发展,对此下拉列表的要求也随之增加:

  • 辅助功能支持:确保您的下拉菜单可供所有人使用,包括那些使用屏幕阅读器或其他辅助技术的人,这又增加了一层复杂性。您需要管理焦点状态、aria属性,并确保您的下拉菜单在语义上是正确的。
  • 键盘导航:用户不应仅限于鼠标交互。他们可能希望使用箭头键导航选项、使用 进行选择Enter或使用 关闭下拉列表Escape。这需要额外的事件侦听器和状态管理。
  • 异步数据注意事项:随着应用程序的扩展,下拉选项可能不再被硬编码。它们可能是从 API 获取的。这就需要管理下拉列表中的加载、错误和空状态。
  • UI 变体和主题:应用程序的不同部分可能需要不同的下拉菜单样式或主题。管理组件内的这些变化可能会导致 props 和配置的爆炸式增长。
  • 扩展功能:随着时间的推移,您可能需要其他功能,例如多选、过滤选项或与其他表单控件的集成。将这些添加到已经很复杂的组件中可能会令人望而生畏。


无头组件是 React 中的一种设计模式,其中组件(通常作为 React hooks 实现)仅负责逻辑和状态管理,而不规定任何特定的 UI(用户界面)。它提供了操作的“大脑”,但将“外观”留给了实现它的开发人员。本质上,它提供功能而不强制特定的视觉表示。

当可视化时,无头组件显示为一个细长的层,一侧与 JSX 视图交互,另一侧在需要时与底层数据模型通信。这种模式对于仅寻求 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 钩子管理 isOpen 和 selectedItem 状态,以控制下拉菜单的行为。只需点击触发器即可切换下拉菜单,而选择一个项目则会更新 selectedItem 状态。

让我们将组件分解成更小、更易于管理的部分,以便更清楚地了解它。这种分解并不是无头组件模式的一部分,但将复杂的用户界面组件分解成小块却是一项有价值的活动。

我们可以先提取一个触发器组件来处理用户点击:

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

触发器组件是一个基本的可点击 UI 元素,包含一个要显示的标签和一个 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 函数。

然后,在下拉组件中,我们将触发器和下拉菜单整合在一起,并为它们提供必要的状态。这种方法确保了 Trigger 和 DropdownMenu 组件与状态无关,而只是对传递的道具做出反应。

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>
  );
};

至此,我们重构后的代码一目了然,每个代码段都简单明了且可调整。修改或引入不同的触发器组件也相对简单。

但是,当我们引入更多的功能和管理更多的状态时,我们当前的组件还能站得住脚吗?

让我们通过一个重要的增强功能来找出答案:键盘导航。

加入键盘导航
在下拉列表中加入键盘导航功能,可替代鼠标交互,从而增强用户体验。这对可访问性尤为重要,可在网页上提供无缝导航体验。让我们探讨一下如何使用 onKeyDown 事件处理程序来实现这一目标。

首先,我们将在下拉组件的 onKeyDown 事件中附加一个 handleKeyDown 函数。在这里,我们利用一个 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` 组件与状态管理代码和呈现逻辑纠缠在一起。它包含了大量的开关情况以及所有的状态管理构造,如 `selectedItem`、`selectedIndex`、`setSelectedItem` 等。

使用自定义钩子实现无头组件
为了解决这个问题,我们将通过名为 useDropdown 的自定义钩子引入无头组件的概念。这个钩子可以有效地封装状态和键盘事件处理逻辑,返回一个包含基本状态和功能的对象。通过在下拉组件中去结构化,我们可以保持代码的整洁和可持续性。

神奇之处在于 useDropdown 钩子,也就是我们的主角--无头组件。这个多功能组件包含了下拉菜单所需的一切:是否打开、选中的项目、突出显示的项目、对回车键的反应等等。它的优点在于适应性强;您可以将其与各种可视化展示--您的 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>
  );
};

通过这些修改,我们成功地在下拉列表中实现了键盘导航,使其更易于访问和使用。这个示例还说明了如何利用钩子以结构化和模块化的方式管理复杂的状态和逻辑,为进一步增强用户界面组件并增加其功能铺平了道路。

这种设计的美妙之处在于它将逻辑与表现截然分开。我们所说的 "逻辑 "是指选择组件的核心功能:打开/关闭状态、所选项目、突出显示的元素,以及对用户输入(如从列表中选择时按下箭头)的反应。这种划分可以确保我们的组件保留其核心行为,而不受限于特定的可视化表现形式,这也是 "无头组件"(Headless Component)一词的由来。

详细点击标题