在前端开发领域,术语和范例有时可能令人费解,“Headless UI”或“Headless 组件”很可能属于这个范畴。如果你对这些术语感到困惑,你并不孤单。实际上,尽管这些概念的名称令人困惑,但它们是一种有吸引力的策略,可以极大地简化复杂用户界面的管理。
2023年11月10日更新:我在Martin Fowler的博客上发表了一篇长文(https://martinfowler.com/articles/headless-component.html)。如果你喜欢更详细的版本,那篇文章应该很合适。
无头组件可能看起来很奇特,但它们的真正威力在于它们的灵活性、可重用性和改善代码库组织和清晰度的能力。在本文中,我们将揭示这种模式的奥秘,阐明它到底是什么,为什么它有益,以及它如何彻底改变你对界面设计的方法。
为了说明这一点,我们将从一个简单而有效的无头组件的应用开始:从两个相似的组件中提取一个“useToggle”钩子来减少代码重复。虽然这个例子可能看起来微不足道,但它为理解无头组件的核心原则铺平了道路。通过识别常见模式并将它们提取为可重用的部分,我们可以简化我们的代码库,并为更高效的开发过程铺平道路。
但这只是冰山一角!随着我们深入探讨,我们将遇到一个更复杂的实例:利用Downshift,一个用于创建增强型输入组件的强大库。
通过本文的阅读,我希望不仅能让你理解无头组件,还能让你有信心将这种强大的模式整合到你自己的项目中。所以,让我们摒弃困惑,拥抱无头组件的变革潜力。
切换是许多应用程序的重要组成部分。它们是“在此设备上记住我”、“激活通知”或非常流行的“暗黑模式”等功能背后的默默工作者。
在React中创建这样一个切换组件的过程非常简单。让我们深入了解如何构建一个。
const ToggleButton = () => {
const [isToggled, setIsToggled] = useState(false);
const toggle = useCallback(() => {
setIsToggled((prevState) => !prevState);
}, []);
return (
<div className="toggleContainer">
<p>请勿打扰</p>
<button onClick={toggle} className={isToggled ? "on" : "off"}>
{isToggled ? "开" : "关"}
</button>
</div>
);
};
useState
钩子设置了一个名为isToggled
的状态变量,初始值为false
。使用useCallback
创建的toggle
函数在每次调用时(点击按钮时)在isToggled
值之间切换true
和false
。按钮的外观和文本("开"或"关")动态反映了isToggled
状态。
现在假设我们需要构建另一个完全不同的组件ExpandableSection,它将显示或隐藏一个部分的详细信息。标题旁边有一个按钮,您可以点击展开或折叠详细信息。
实现也不太困难,你可以轻松地这样做:
const ExpandableSection = ({ title, children }: ExpandableSectionType) => {
const [isOpen, setIsOpen] = useState(false);
const toggleOpen = useCallback(() => {
setIsOpen((prevState) => !prevState);
}, []);
return (
<div>
<h2 onClick={toggleOpen}>{title}</h2>
{isOpen && <div>{children}</div>}
</div>
);
};
这里有一个明显的相似之处 - ToggleButton
中的“开”和“关”状态与ExpandableSection
中的“展开”和“折叠”操作相似。认识到这种共同点,我们可以将这个共享功能抽象成一个单独的函数。在React生态系统中,我们通过创建自定义钩子来实现这一点。
const useToggle = (init = false) => {
const [state, setState] = useState(init);
const toggle = useCallback(() => {
setState((prevState) => !prevState);
}, []);
return [state, toggle];
};
这个重构可能看起来相当简单,但它突出了一个重要的概念:将行为与呈现分离。在这种情况下,我们的自定义钩子作为一个独立于JSX的状态机。ToggleButton
和ExpandableSection
都利用了这个相同的基础逻辑。
任何在中等规模的前端项目上花费了相当时间的人都会意识到,大多数更新或错误与UI视觉无关,而是与管理UI的状态相关 - 本质上是与逻辑相关。钩子为集中处理这些逻辑方面提供了强大的工具,使其更容易进行审查、优化和维护。
实际上,已经有许多优秀的库使用这种模式来分离行为(或状态管理)和呈现。在这些组件库中,最著名的应该是Downshift。
Downshift应用了无头组件的概念,这些组件管理行为和状态,而不渲染任何UI。它们在其渲染属性函数中提供了一个状态和一组操作,允许你将其连接到你的UI上。这样,Downshift允许你控制你的UI,同时它负责复杂的状态和可访问性管理。
例如,我想构建一个下拉列表,显然我需要列表数据、一个触发器和一些关于如何突出显示选定项、应该渲染多少行的自定义设置。但我不想从头开始构建可访问性,因为有许多边缘情况需要考虑,包括跨浏览器和跨设备的适应性。
所以只需几行JSX,我就可以轻松地使用Downshift创建一个完全可访问的选择器:
const StateSelect = () => {
const {
isOpen,
selectedItem,
getToggleButtonProps,
getLabelProps,
getMenuProps,
highlightedIndex,
getItemProps,
} = useSelect({items: states});
return (
<div>
<label {...getLabelProps()}>发行状态:</label>
<div {...getToggleButtonProps()} className="trigger" >
{selectedItem ?? '选择一个状态'}
</div>
<ul {...getMenuProps()} className="menu">
{isOpen &&
states.map((item, index) => (
<li
style={
highlightedIndex === index ? {backgroundColor: '#bde4ff'} : {}
}
key={`${item}${index}`}
{...getItemProps({item, index})}
>
{item}
</li>
))}
</ul>
</div>
)
}
这个组件是一个使用Downshift的useSelect
钩子的状态选择器。它允许用户从下拉菜单中选择一个状态。
-
useSelect
管理选择输入的状态和交互。 -
isOpen
、selectedItem
和highlightedIndex
是由useSelect
控制的状态变量。 -
getToggleButtonProps
、getLabelProps
、getMenuProps
和getItemProps
是用于提供相应元素所需属性的函数。 -
isOpen
确定下拉菜单是否打开。 -
selectedItem
保存当前选定状态的值。 -
highlightedIndex
指示当前突出显示的列表项。 -
如果下拉菜单打开,
states.map
会生成一个可选择的状态的无序列表。 -
使用扩展运算符(
...
)将属性从Downshift的钩子传递给组件。这包括点击处理程序、键盘导航和ARIA属性等。 -
如果选择了一个状态,它将显示为按钮内容。否则,它显示为“选择一个状态”。
这种方法使你完全控制渲染,因此你可以根据应用程序的外观和感觉来设计你的组件,并在必要时应用自定义行为。它也非常适合在不同的组件或项目之间共享行为逻辑。
还有一些遵循这种模式的无头组件库:
-
Reakit:它提供了一组用于构建可访问的高级UI库、工具包、设计系统等的无头组件。
-
React Table:它是一个可组合的无头实用工具,基于Hooks,可以构建各种表格。
-
react-use:它是一个包含多个无头组件的Hooks集合。
当我们以有意识的方式继续将逻辑与UI分离时,我们逐渐创建了一个分层结构。这个结构不是跨整个应用程序的传统分层架构,而是特定于应用程序的UI部分。
在这种架构中,JSX(或大多数标签)在最高层定义,它专门负责显示传入的属性。紧接着,我们有一个所谓的“无头组件”。这个组件维护所有的行为,管理状态,并为JSX提供交互的接口。在这个结构的底部,我们有封装领域特定逻辑的数据模型。这些模型不关心UI或状态,而是专注于数据管理和业务逻辑。这种分层的方法提供了一种清晰分离关注点的方式,增强了代码的清晰度和可维护性。
与任何其他类型的技术一样,无头UI也有一些需要注意的优点和缺点。让我们先讨论无头UI的好处:
-
可重用性:无头组件的主要优势是它们的可重用性。通过将逻辑封装到独立的组件中,您可以在多个UI元素中重用这些组件。这不仅减少了代码重复,还促进了应用程序的一致性。
-
关注点分离:无头组件清晰地分离了逻辑和呈现。这使得您的代码库更易于管理和理解,特别是对于具有分工的大型团队而言。
-
灵活性:由于无头组件不指定呈现方式,它们允许更大的设计灵活性。您可以根据需要自定义UI,而不影响底层逻辑。
-
可测试性:由于逻辑与呈现分离,编写业务逻辑的单元测试更容易。
另一方面,与一个全功能组件相比,它通常会更复杂一些,因此需要考虑以下几点:
-
初始开销:对于较简单的应用程序或组件,创建无头组件可能会显得过度工程化,导致不必要的复杂性。
-
学习曲线:对于不熟悉这个概念的开发人员来说,理解起来可能会有一定的挑战,导致学习曲线较陡。
-
可能过度使用:很容易过度使用并尝试使每个组件都成为无头组件,即使在不必要的情况下,导致代码库过于复杂。
-
潜在的性能问题:虽然通常不是一个重大问题,但如果不小心处理使用共享逻辑重新渲染多个组件可能会导致性能问题。
请记住,无头UI并不是像任何架构模式一样适用于所有情况的解决方案。使用它的决定应该基于您项目的特定需求和复杂性。
在本文中,我们深入探讨了无头用户界面的世界,这是一种处理复杂UI任务的有力方法。我们探讨了将行为与渲染分离的方式如何帮助我们创建更易于维护和重用的代码,减少冗余和潜在的错误。我们首先通过一个简单的示例来说明这一点,创建了一个自定义的React钩子useToggle
,并展示了它在两个独立的组件中的应用。然后,我们将这个概念扩展到一个更复杂的场景中,使用了一个名为Downshift的优秀库,该库方便构建增强型输入组件。通过更深入地理解“无头”方法,我们希望您能够在未来的项目中利用这种模式来创建更可扩展和可维护的UI。