Headless UI 设计模式
逻辑与 UI 分离的现代组件设计理念
目录
核心理念
什么是 Headless UI?
Headless UI = 行为逻辑 + 状态管理 + 无障碍支持,不包含样式
传统组件:
┌─────────────────────────────────┐
│ 样式 + 逻辑 + 状态 + 可访问性 │ 紧耦合,难定制
└─────────────────────────────────┘
Headless 组件:
┌─────────────────────────────────┐
│ 你的自定义样式 │ ← 完全控制
├─────────────────────────────────┤
│ Headless: 逻辑 + 状态 + a11y │ ← 库提供
└─────────────────────────────────┘适用场景
| 场景 | 适合 Headless | 适合传统组件库 |
|---|---|---|
| 设计要求 | 高度定制化 UI | 快速出原型 |
| 品牌一致性 | 需要独特风格 | 接受标准样式 |
| 团队能力 | 有设计系统 | 快速交付 |
设计模式
模式一:Render Props
tsx
interface ToggleRenderProps {
on: boolean;
toggle: () => void;
}
function Toggle({ children }: { children: (props: ToggleRenderProps) => ReactNode }) {
const [on, setOn] = useState(false);
const toggle = () => setOn(prev => !prev);
return children({ on, toggle });
}
// 使用
<Toggle>
{({ on, toggle }) => (
<button onClick={toggle}>
{on ? 'ON' : 'OFF'}
</button>
)}
</Toggle>模式二:Compound Components
tsx
const TabsContext = createContext<{
activeIndex: number;
setActiveIndex: (index: number) => void;
} | null>(null);
function Tabs({ children, defaultIndex = 0 }: { children: ReactNode; defaultIndex?: number }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
{children}
</TabsContext.Provider>
);
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
function Tab({ index, children }: { index: number; children: ReactNode }) {
const context = useContext(TabsContext);
if (!context) throw new Error('Tab must be used within Tabs');
const { activeIndex, setActiveIndex } = context;
return (
<button
role="tab"
aria-selected={activeIndex === index}
onClick={() => setActiveIndex(index)}
>
{children}
</button>
);
}
function TabPanel({ index, children }: { index: number; children: ReactNode }) {
const context = useContext(TabsContext);
if (!context) throw new Error('TabPanel must be used within Tabs');
if (context.activeIndex !== index) return null;
return <div role="tabpanel">{children}</div>;
}
// 组合使用
<Tabs defaultIndex={0}>
<TabList>
<Tab index={0}>Tab 1</Tab>
<Tab index={1}>Tab 2</Tab>
</TabList>
<TabPanel index={0}>Content 1</TabPanel>
<TabPanel index={1}>Content 2</TabPanel>
</Tabs>模式三:Custom Hooks
tsx
function useDisclosure(initialOpen = false) {
const [isOpen, setIsOpen] = useState(initialOpen);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
return { isOpen, open, close, toggle };
}
function useModal() {
const disclosure = useDisclosure();
const modalRef = useRef<HTMLDivElement>(null);
// ESC 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') disclosure.close();
};
if (disclosure.isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [disclosure.isOpen, disclosure.close]);
// 焦点陷阱
useEffect(() => {
if (disclosure.isOpen && modalRef.current) {
modalRef.current.focus();
}
}, [disclosure.isOpen]);
return { ...disclosure, modalRef };
}
// 使用
function MyModal() {
const { isOpen, open, close, modalRef } = useModal();
return (
<>
<button onClick={open}>Open Modal</button>
{isOpen && (
<div ref={modalRef} tabIndex={-1} className="modal">
<p>Modal Content</p>
<button onClick={close}>Close</button>
</div>
)}
</>
);
}实战案例
Headless Dropdown
tsx
import { useState, useRef, useEffect, createContext, useContext } from 'react';
// Context
const DropdownContext = createContext<{
isOpen: boolean;
toggle: () => void;
close: () => void;
selectedValue: string | null;
select: (value: string) => void;
} | null>(null);
// Root
function Dropdown({ children, onSelect }: { children: ReactNode; onSelect?: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | null>(null);
const ref = useRef<HTMLDivElement>(null);
const toggle = () => setIsOpen(prev => !prev);
const close = () => setIsOpen(false);
const select = (value: string) => {
setSelectedValue(value);
onSelect?.(value);
close();
};
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
close();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<DropdownContext.Provider value={{ isOpen, toggle, close, selectedValue, select }}>
<div ref={ref} style={{ position: 'relative' }}>
{children}
</div>
</DropdownContext.Provider>
);
}
// Trigger
function DropdownTrigger({ children }: { children: ReactNode }) {
const context = useContext(DropdownContext);
if (!context) throw new Error('Must be used within Dropdown');
return (
<button onClick={context.toggle} aria-expanded={context.isOpen}>
{children}
</button>
);
}
// Menu
function DropdownMenu({ children }: { children: ReactNode }) {
const context = useContext(DropdownContext);
if (!context || !context.isOpen) return null;
return (
<ul role="menu" style={{ position: 'absolute', top: '100%', left: 0 }}>
{children}
</ul>
);
}
// Item
function DropdownItem({ value, children }: { value: string; children: ReactNode }) {
const context = useContext(DropdownContext);
if (!context) throw new Error('Must be used within Dropdown');
return (
<li role="menuitem" onClick={() => context.select(value)}>
{children}
</li>
);
}
// 使用
<Dropdown onSelect={(v) => console.log('Selected:', v)}>
<DropdownTrigger>Select Option</DropdownTrigger>
<DropdownMenu>
<DropdownItem value="a">Option A</DropdownItem>
<DropdownItem value="b">Option B</DropdownItem>
<DropdownItem value="c">Option C</DropdownItem>
</DropdownMenu>
</Dropdown>主流库对比
| 库 | 特点 | 样式方案 | Bundle Size |
|---|---|---|---|
| Radix UI | 功能全面、a11y 完善 | 完全无样式 | ~20KB |
| Headless UI | Tailwind 官方、简洁 | 配合 Tailwind | ~15KB |
| React Aria | Adobe 出品、最完善 a11y | 完全无样式 | ~25KB |
| Downshift | 专注输入 (Combobox) | 完全无样式 | ~10KB |
| Ariakit | 现代 Hooks API | 完全无样式 | ~18KB |
Radix UI 示例
tsx
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
<DropdownMenu.Root>
<DropdownMenu.Trigger className="trigger">
Options
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="content">
<DropdownMenu.Item className="item">
New Tab
</DropdownMenu.Item>
<DropdownMenu.Separator className="separator" />
<DropdownMenu.Item className="item" disabled>
Disabled
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>Headless UI 示例
tsx
import { Menu } from '@headlessui/react';
<Menu>
<Menu.Button className="...">Options</Menu.Button>
<Menu.Items className="...">
<Menu.Item>
{({ active }) => (
<a className={`${active ? 'bg-blue-500' : ''}`} href="#">
Edit
</a>
)}
</Menu.Item>
</Menu.Items>
</Menu>无障碍支持
ARIA 角色与属性
tsx
// 正确的 ARIA 实现
<div role="menu" aria-labelledby="menu-button">
<button
id="menu-button"
aria-haspopup="true"
aria-expanded={isOpen}
>
Menu
</button>
{isOpen && (
<ul role="menu">
<li role="menuitem" tabIndex={0}>Item 1</li>
<li role="menuitem" tabIndex={-1}>Item 2</li>
</ul>
)}
</div>键盘导航
typescript
function useArrowNavigation(items: HTMLElement[]) {
const [focusIndex, setFocusIndex] = useState(0);
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusIndex(prev => (prev + 1) % items.length);
break;
case 'ArrowUp':
e.preventDefault();
setFocusIndex(prev => (prev - 1 + items.length) % items.length);
break;
case 'Home':
setFocusIndex(0);
break;
case 'End':
setFocusIndex(items.length - 1);
break;
}
};
useEffect(() => {
items[focusIndex]?.focus();
}, [focusIndex, items]);
return { handleKeyDown, focusIndex };
}高频面试题
Q1: Headless 组件的优势是什么?
- 样式自由:100% 控制 UI,无需覆盖默认样式
- 逻辑复用:行为逻辑可在不同设计间复用
- Bundle 小:不包含样式代码
- a11y 内置:专业的无障碍支持
Q2: Compound Components 如何实现?
通过 Context + 组件组合:
- 父组件创建 Context 提供状态
- 子组件通过
useContext消费状态 - 子组件独立渲染,父组件协调行为
Q3: 如何处理复杂组件的状态机?
使用 有限状态机(如 XState):
typescript
const dropdownMachine = createMachine({
id: 'dropdown',
initial: 'closed',
states: {
closed: {
on: { TOGGLE: 'open', OPEN: 'open' }
},
open: {
on: {
TOGGLE: 'closed',
CLOSE: 'closed',
SELECT: { target: 'closed', actions: 'selectItem' }
}
}
}
});Q4: 如何测试 Headless 组件?
tsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('dropdown opens on trigger click', async () => {
render(<Dropdown />);
const trigger = screen.getByRole('button');
await userEvent.click(trigger);
expect(screen.getByRole('menu')).toBeInTheDocument();
});
test('dropdown supports keyboard navigation', async () => {
render(<Dropdown />);
await userEvent.click(screen.getByRole('button'));
await userEvent.keyboard('{ArrowDown}');
expect(screen.getAllByRole('menuitem')[0]).toHaveFocus();
});设计模式选择指南
| 场景 | 推荐模式 |
|---|---|
| 简单开关状态 | Custom Hooks |
| 多个相关子组件 | Compound Components |
| 高度自定义渲染 | Render Props |
| 复杂状态流转 | 状态机 + Hooks |