Skip to content

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 UITailwind 官方、简洁配合 Tailwind~15KB
React AriaAdobe 出品、最完善 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 组件的优势是什么?

  1. 样式自由:100% 控制 UI,无需覆盖默认样式
  2. 逻辑复用:行为逻辑可在不同设计间复用
  3. Bundle 小:不包含样式代码
  4. a11y 内置:专业的无障碍支持

Q2: Compound Components 如何实现?

通过 Context + 组件组合

  1. 父组件创建 Context 提供状态
  2. 子组件通过 useContext 消费状态
  3. 子组件独立渲染,父组件协调行为

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

前端面试知识库