阅读视图

发现新文章,点击刷新页面。

前端向架构突围系列模块化 [4 - 2]:逻辑与视图的极致分离(Headless UI)

写在前面

你是否经历过这样的场景:

你的团队维护了一个功能强大的 <SuperSelect /> 组件,集成了搜索、多选、远程加载、虚拟滚动。 某天,产品经理走过来说:“这个下拉框在移动端能不能变成一个从底部弹出的半屏抽屉(ActionSheet)?逻辑不变,就是样式改改。”

你看着那 2000 行包含着 divulli 和无数 CSS 类的代码,陷入了绝望。你发现根本改不动,因为交互逻辑(打开/关闭/选中)DOM 结构 像是纠缠在一起的藤蔓,剪不断理还乱。

这就是逻辑与视图强耦合的代价。

本篇我们将探讨 Headless UI(无头组件) 模式。它不仅是 Shadcn/UI、TanStack Table 背后的秘密武器,更是前端架构师解耦复杂业务组件的必修课。

image.png


一、 进化的必然:从“全家桶”到“发动机”

在组件化发展的早期(Bootstrap、AntD v3 时代),我们推崇的是 "All-in-One" 模式。一个组件包办一切:

  • 状态(State): isOpen, selectedIndex
  • 行为(Behavior): 点击打开、键盘回车选中、ESC 关闭
  • 样式(Style): CSS Class, Styled-components
  • 结构(Markup): <div>, <span>

这种模式在业务初期跑得很快,但随着设计系统的迭代,它很快就会变成**“配置地狱”**。你一定见过这种组件 API:

// 典型的“过度封装”组件
<Table 
  data={data}
  useVirtualScroll={true}
  // 为了改一个样式,不得不暴露无数个 render props
  renderHeader={(props) => <div className="bg-red-500" {...props} />} 
  rowClassName={(record) => record.active ? 'bg-blue' : ''}
  dropdownStyle={{ zIndex: 9999 }} // 甚至开始直接透传 CSS
/>

架构反思: 这种设计的本质错误在于:试图用有限的配置(Props),去穷尽无限的 UI 变化。 视图(View)是易变的,就像时尚潮流;而逻辑(Logic)是相对稳定的,就像人体骨骼。把它们焊死在一起,必然会导致僵化。

于是,Headless UI 应运而生。它的核心哲学只有一句话:我给你提供逻辑的“发动机”,你自己去造“车壳子”。


二、 Headless UI 的解剖学:只有大脑,没有皮肤

所谓的“无头”,指的是不渲染具体的 DOM 节点(或者只渲染最语义化的标签),不包含任何样式,只负责提供交互逻辑和可访问性(A11y)。

2.1 两种主流实现形态

在 React 和 Vue 的现代生态中,Headless 主要有两种落地形态:

形态一:Hooks / Composables (纯逻辑层)

这是最彻底的分离。组件完全消失,只剩下一个函数。

  • React 示例 (useToggle):

    // Headless 逻辑
    function useToggle() {
      const [on, setOn] = useState(false);
      const toggle = () => setOn(!on);
      // 返回状态和绑定到 DOM 上的属性
      return { 
        on, 
        toggle, 
        togglerProps: { 
          'aria-pressed': on, 
          onClick: toggle 
        } 
      };
    }
    
    // UI 实现 A:普通的按钮
    function ButtonToggle() {
      const { on, togglerProps } = useToggle();
      return <button {...togglerProps}>{on ? 'ON' : 'OFF'}</button>;
    }
    
    // UI 实现 B:复杂的 Switch 开关
    function SwitchToggle() {
      const { on, togglerProps } = useToggle();
      return (
        <div {...togglerProps} className={`switch ${on ? 'active' : ''}`}>
          <div className="slider" />
        </div>
      );
    }
    

形态二:Render Props / Slots (组件容器层)

这种形态通常用于涉及父子组件通信的复杂场景(如 Select, Tabs)。它负责处理 DOM 的层级关系和键盘导航,但把渲染权交还给用户。

  • 代表库: Headless UI (Tailwind Labs), Radix UI.
// Radix UI 风格
<Tabs.Root defaultValue="tab1">
  <Tabs.List>
    <Tabs.Trigger value="tab1">Account</Tabs.Trigger>
    <Tabs.Trigger value="tab2">Password</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="tab1">...</Tabs.Content>
  <Tabs.Content value="tab2">...</Tabs.Content>
</Tabs.Root>

注意:这里的 <Tabs.Root> 并不强制你输出特定的 div 结构,它主要负责上下文(Context)的传递键盘焦点的管理


三、 架构师的实战:如何设计一个 Headless 组件?

假设我们要设计一个企业级的 Datepicker(日期选择器) 。如果按照传统思路,你会被 CSS 搞死。如果按照 Headless 思路,你应该关注什么?

3.1 步骤一:提取“纯数据模型”

首先,剥离任何与 DOM 无关的数学逻辑。这部分代码应该是纯 JS/TS,可以在 Node.js 里跑单测。

class CalendarModel {
  // 给定年月,返回一个 6x7 的二维数组,包含日期、是否是上月残留、是否禁用等信息
  generateGrid(year: number, month: number): DateCell[][] { ... }
  
  // 判断两个日期是否相等
  isSameDate(d1: Date, d2: Date): boolean { ... }
}

3.2 步骤二:封装“交互钩子”

接下来,处理用户的交互行为。这是 Headless 的核心。

// useCalendar.ts
export function useCalendar({ selectedDate, onChange }) {
  const [viewDate, setViewDate] = useState(new Date()); // 当前查看的月份

  const grid = useMemo(() => new CalendarModel().generateGrid(...), [viewDate]);

  const selectDate = (date) => {
    onChange(date);
    // 只有逻辑,没有样式
  };

  const nextMonth = () => setViewDate(d => addMonths(d, 1));

  // 关键:返回 Props Getter 而不是直接返回 JSX
  // 这就是 Inversion of Control (控制反转)
  const getDayProps = (dateCell) => ({
    onClick: () => selectDate(dateCell.date),
    role: 'gridcell',
    'aria-selected': isSameDate(dateCell.date, selectedDate),
    'aria-disabled': dateCell.disabled,
    tabIndex: isSameDate(dateCell.date, viewDate) ? 0 : -1, // 键盘导航逻辑
  });

  return { grid, viewDate, nextMonth, getDayProps };
}

3.3 步骤三:注入视图(UI层)

最后,才是业务开发者干活的地方。

function MyDatePicker() {
  const { grid, getDayProps } = useCalendar({...});

  return (
    <div className="my-calendar-wrapper">
      {grid.map(row => (
        <div className="row">
          {row.map(cell => (
            // 只需要解构 props,所有的交互、A11y 自动生效
            <span className="cell" {...getDayProps(cell)}>
              {cell.date.getDate()}
            </span>
          ))}
        </div>
      ))}
    </div>
  );
}

架构收益:

  1. UI 自由: 你可以用 table 渲染,也可以用 div+flex 渲染,甚至可以在 Canvas 里渲染(只要能透传事件)。
  2. 测试稳定: 你只需要针对 useCalendar 编写逻辑测试用例,覆盖率 100%。UI 层的变化不会导致逻辑测试失败。
  3. 多端复用: 这个 useCalendar 可以无缝移植到 React Native,因为里面没有 HTMLDivElement

四、 复杂度的克星:TanStack Table 的启示

如果说 toggle 是小儿科,那么表格(Table)就是前端复杂度的珠穆朗玛峰。 TanStack Table (原 React Table) 是 Headless 理念的集大成者。

拒绝渲染任何 HTML。它不给你 <Table> 组件,它给你的是:

  • useReactTable() 钩子
  • getRowModel() 核心算法
  • getSortedRowModel() 排序算法

开发者: "那表格长什么样?" TanStack:*"我怎么知道?你可以用 HTML table,也可以用 CSS Grid 模拟的虚拟列表 div。我只告诉你,第二行第三列的数据是什么,以及它现在是不是被选中状态。"

这种设计带来的爆发力是惊人的: 企业 A 想要一个类似 Excel 的表格;企业 B 想要一个类似 Trello 的看板(本质也是数据列表)。 在 Headless 架构下,它们可以使用同一套核心逻辑(排序、筛选、分页、分组) ,仅仅是 View 层不同。这在传统组件库(如 AntD Table)中是几乎不可能做到的。


五、 陷阱与权衡:何时不该 Headless?

Headless UI 虽然优雅,但它是高成本的。

5.1 缺点:

  1. 上手门槛高: 开发者不能“开箱即用”。为了画一个简单的下拉框,可能需要写 50 行代码来组装 Headless 的各个部件。
  2. 样式管理负担: 所有 CSS 都要自己写,或者依赖 Tailwind。

5.2 架构分层策略

作为架构师,你不应该让所有业务开发都直接使用 Headless 底层。你应该采用 “三层架构”

  1. Level 1: Headless Core (逻辑层)

    • 使用 useSelect, Radix Select
    • 处理 ARIA、键盘事件、状态管理。
    • 面向对象:资深开发 / 架构组。
  2. Level 2: Styled System Component (标准组件层)

    • 引入公司的 Design Token。
    • 封装样式:<CompanySelect /> = Radix Select + Tailwind Classes
    • 面向对象:业务线通用开发。
  3. Level 3: Business Component (业务组件层)

    • 绑定业务数据:<UserSelect /> = <CompanySelect /> + fetchUserList API
    • 面向对象:初级开发 / 实习生。

通过这种分层,我们既保留了 Headless 的灵活性(底层可换),又保证了业务开发的高效性(顶层开箱即用)。


结语:控制反转的艺术

组件化设计的最高境界,不是你帮用户把所有事情都做了,而是你把控制权优雅地交还给用户

Headless UI 就像是把组件的“灵魂”提取了出来,允许用户随心所欲地塑造“肉体”。理解了这一点,你就掌握了应对前端需求万变不离其宗的法门。

Next Step: 有了灵活的组件骨架(Headless UI),如果组件之间需要复杂的通信(比如一个树形组件,子节点要通知祖先节点),或者需要跨层级的状态共享,仅仅靠 Props 传递显然是不够的。 下一节,我们将探讨**《第三篇:骨架(下)——组件化深度设计:复杂组件的通信与组合模式》**

❌