阅读视图
前端向架构突围系列模块化 [4 - 2]:逻辑与视图的极致分离(Headless UI)
写在前面
你是否经历过这样的场景:
你的团队维护了一个功能强大的
<SuperSelect />组件,集成了搜索、多选、远程加载、虚拟滚动。 某天,产品经理走过来说:“这个下拉框在移动端能不能变成一个从底部弹出的半屏抽屉(ActionSheet)?逻辑不变,就是样式改改。”你看着那 2000 行包含着
div、ul、li和无数 CSS 类的代码,陷入了绝望。你发现根本改不动,因为交互逻辑(打开/关闭/选中) 和 DOM 结构 像是纠缠在一起的藤蔓,剪不断理还乱。这就是逻辑与视图强耦合的代价。
本篇我们将探讨 Headless UI(无头组件) 模式。它不仅是 Shadcn/UI、TanStack Table 背后的秘密武器,更是前端架构师解耦复杂业务组件的必修课。
一、 进化的必然:从“全家桶”到“发动机”
在组件化发展的早期(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>
);
}
架构收益:
-
UI 自由: 你可以用
table渲染,也可以用div+flex渲染,甚至可以在 Canvas 里渲染(只要能透传事件)。 -
测试稳定: 你只需要针对
useCalendar编写逻辑测试用例,覆盖率 100%。UI 层的变化不会导致逻辑测试失败。 -
多端复用: 这个
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 缺点:
- 上手门槛高: 开发者不能“开箱即用”。为了画一个简单的下拉框,可能需要写 50 行代码来组装 Headless 的各个部件。
- 样式管理负担: 所有 CSS 都要自己写,或者依赖 Tailwind。
5.2 架构分层策略
作为架构师,你不应该让所有业务开发都直接使用 Headless 底层。你应该采用 “三层架构” :
-
Level 1: Headless Core (逻辑层)
- 使用
useSelect,Radix Select。 - 处理 ARIA、键盘事件、状态管理。
- 面向对象:资深开发 / 架构组。
- 使用
-
Level 2: Styled System Component (标准组件层)
- 引入公司的 Design Token。
- 封装样式:
<CompanySelect />=Radix Select+Tailwind Classes。 - 面向对象:业务线通用开发。
-
Level 3: Business Component (业务组件层)
- 绑定业务数据:
<UserSelect />=<CompanySelect />+fetchUserList API。 - 面向对象:初级开发 / 实习生。
- 绑定业务数据:
通过这种分层,我们既保留了 Headless 的灵活性(底层可换),又保证了业务开发的高效性(顶层开箱即用)。
结语:控制反转的艺术
组件化设计的最高境界,不是你帮用户把所有事情都做了,而是你把控制权优雅地交还给用户。
Headless UI 就像是把组件的“灵魂”提取了出来,允许用户随心所欲地塑造“肉体”。理解了这一点,你就掌握了应对前端需求万变不离其宗的法门。
Next Step: 有了灵活的组件骨架(Headless UI),如果组件之间需要复杂的通信(比如一个树形组件,子节点要通知祖先节点),或者需要跨层级的状态共享,仅仅靠 Props 传递显然是不够的。 下一节,我们将探讨**《第三篇:骨架(下)——组件化深度设计:复杂组件的通信与组合模式》**