为 2C 的 H5 前端应用构建一个 Headless UI 组件库
背景
原来一直做 B 端应用,使用的都是各种各样的组件库,定制化也往往是在组件库上做一层自己的封装,底层的能力,如 DOM 操作,还是组件库提供的。一直以来,我以为 C 端的 H5 应用也使用组件库相应的移动端版本,结果在接触了之后发现,大多数用的 C 端组件居然几乎都是自己实现的,很多组件来源已不可考,各个不同的项目之间也由此产生了大量的复制粘贴......
不过存在即合理,业务发展方面造成的影响暂且不论,技术上的原因还是有一定通用性的。这么做的主要问题来自于 C 端应用对 UI 的要求更高,都用同一套组件库难以满足业务的需求,定制化的 UI 需求非常多,用组件库将有大量的样式复写,提高开发成本,C 端的 UI 功能定制化要求也远多于 B 端应用,经常会出现一些定制化弹窗,定制化弹板,定制化这个那个的需求,导致组件库覆盖的场景较 B 端应用小了不少。即便如此,C 端的 H5 组件却肩负着一个重任 ------ 兼容性。由于移动端环境不统一,同样的代码在不同的环境下很可能有不同的表现,所以最好将相同的功能以相同的组件提供,在组件中对齐不同环境的表现,减少基础设施可能出现的问题。
在需求和实现矛盾的情况下,Headless UI 可能是一个不错的选择。它即可以应对 UI 多样性,又可以保证功能的一致性,统一 C 端应用的基础设施,降低业务侧的开发成本。
什么是 Headless UI
这个部分基本来自于 GPT,我进行了一些修改
Headless UI 是一种开发 UI 组件的方式,特色在于"headless",也就是"无头"设计的概念。"无头"意味着进行了功能封装的同时,却彻底摒弃了具体的界面样式,这充分体现了其极度灵活配置的特性。
优势
在使用传统的 UI 组件库时,我们通常受限于预设的样式和功能,要想去定制组件,就需要付出复杂的配置和覆盖样式的成本。而 Headless UI 提供了一种全新的解决方案:所有 UI 元素都是"无样式"的,同时封装了所需的功能性,让我们可以在自定义 UI 的同时,又无需考虑相应功能的实现。
例如,在实现一个模态对话框时,我们不仅需要设计对话框的外观,同时还需要考虑其打开、关闭、居中显示等交互性功能。使用 Headless UI 的组件库时,业务开发只需专注于对话框外观的设计,后续的行为和功能交互则可以通过 Headless UI 组件提供的接口来实现。因此,无论是单一的按钮,还是复杂的下拉菜单,都可以通过 Headless UI 灵活的应对 UI 的需求,同时保证基础功能的实现。这种方式不仅节省了开发时间,提高了工作效率,还允许开发者拥有前所未有的自由性,尽可能的发挥创意,设计出独一无二的 UI。
总的来说,Headless UI 的特点是具有可复用的功能性和高度自定义的可能性,因此它非常适合于需要大量定制 UI 的场景,这个特点,与 C 端应用的需求非常契合。
问题
Headless UI 的设计理念确实为开发者提供了极大的自由度和灵活性,但如同任何技术和工具,它也不无问题和挑战,主要在开发成本、使用成本和维护成本三方面。
-
开发成本:由于 Headless UI 没有预设样式,这意味着开发者需要为每个组件自行设计并实施样式。这往往需要配合一定的设计资源和 CSS 技术能力,可能增加项目的开发成本。同时,由于自由度的提升,你可能在一些特殊的使用场景或需要复杂行为的组件中需要编写额外的代码,增加了本身 Headless UI 组件库的开发量。
-
使用成本:尽管 Headless UI 提供了丰富的 API 接口和易用的组件,但相比与传统组件库有更高的学习成本。开发者需要深入理解每个组件的 API 以及如何和其他样式库,如 Tailwind CSS ,配合使用来创建满足需求的 UI。如果团队中更习惯于使用预设样式的组件库,那么可能需要花费更多的时间来适应这种全新的开发方式。
-
维护成本:由于每个组件的样式大多是自定义的,每当项目的 UI 设计发生变更时,你可能需要对相应的组件样式进行修改,这可能导致较高的维护成本。此外,由于组件本身只有功能,没有样式,所以传统的人肉 E2E 的测试方式在 Headless UI 的场景下有更多的挑战,为保证质量,可能需要在单测等工具上投入更多成本,或借助 Storybook 等工具,提供一套默认的 UI,这也是现在大多数 Headless UI 组件库的应对办法。
怎么实现
下面以一个 Select 组件作为例子,提供两种实现 Headless UI 的思路.两种方式并不互斥,可以同时使用或提供,甚至我个人认为最好的 Headless UI 的库,应该把灵活性发挥到极致,提供多种实施方式,并提供默认 UI 的"有头"组件库,供业务方使用。
个人认为,实现的方式并不重要,开源的方案有很多可以参考的,关键是为什么要这么做。所以这里找了个现成的例子,仅作为示意。 该例子的完成链接:martinfowler.com/articles/he...
Context
typescript
type DropdownContextType<T> = {
isOpen: boolean
toggleDropdown: () => void
selectedIndex: number
selectedItem: T | null
updateSelectedItem: (item: T) => void
getAriaAttributes: () => any
dropdownRef: RefObject<HTMLElement>
}
// 使用 Context 在组件内部共享状态和功能
const DropdownContext = createContext<DropdownContextType<T> | null>(null)
export const useDropdownContext = () => {
const context = useContext(DropdownContext)
if (!context) {
throw new Error('Components must be used within a <Dropdown/>')
}
return context
}
// Headless UI 组件通过 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.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>
)
}
hooks
tsx
// 定义承载功能的自定义 hook
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,
}
}
// 使用 hook 实现组件,在组件中实施样式
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 UI 方案相较传统的组件库在灵活性上有很大的优势,同时可以将需要的功能封装到一起,保证基础设施功能的完善,这与 2C H5 应用的需要十分契合。此外,也可以根据业务需要,基于 Headless UI 方案额外构建一套默认"有头"的 UI 组件库,同时达到传统组件库的效果。尽管 Headless UI 的启动成本相较于传统的方案更高,构建一套成熟的 Headless UI 组件库依然磨刀不误砍柴工,可以降低业务侧的开发成本,提供 H5 需要的功能,统一解决兼容性问题,提高项目的可维护性,让业务侧的开发者更专注于实现业务价值。
参考资料
www.jacobparis.com/content/rea... medium.com/@nirbenyair... martinfowler.com/articles/he...