headless 理念与框架无关设计
本教程共 38 篇 · 第 2 篇 · 更新于 2026-07-27 · 约 8 分钟阅读
2. headless 理念与框架无关设计
本节目标:理解 TanStack 为什么是 headless 的、框架无关怎么做到的、类型安全体现在哪,学完你能明白这套设计背后的取舍,知道它适不适合你的项目。
2.1 什么是 headless UI
先打个比方。你去家具店买衣柜,有两种选择:
一种是成品衣柜,样式、颜色、隔层都定好了,搬回家就能用,但你想改个内部结构很难。
另一种是衣柜系统,给你导轨、挂杆、抽屉滑轨这些零件,你自己搭柜体外壳,里面怎么分隔你说了算。
headless UI 就是后者。它给你逻辑、状态、数据处理这些最麻烦的部分,但不给你 markup 和样式。UI 长什么样,完全由你决定。
TanStack 官方对 headless 的定义是:提供 UI 元素的逻辑、状态处理和 API,但不提供标签、样式或预构建实现。复杂 UI 最难的部分往往是状态管理、事件处理、副作用,把这些从样式里剥离出来,逻辑就变得模块化、可复用。
2.2 headless 长什么样
拿 Table 举个例子。用传统的预构建表格组件,你这么写:
// 把数据扔进去,祈祷它的样式能改
<PrebuiltDataGrid
data={data}
columns={columns}
theme={一堆主题覆盖}
sx={一堆CSS覆盖}
/>
用 TanStack Table,你拿到的是一个表格实例,状态和 API 都在你手里,自己渲染:
import { useReactTable, getCoreRowModel } from '@tanstack/react-table'
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
})
return (
<table className="随便你写什么样式">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
看着代码多了不少,但你拿到的是完全的控制权。想加虚拟化?在 <tbody> 里接 Virtual 就行。想换 shadcn/ui 的样式?className 随便改。想用 Tailwind?没问题。
Tipheadless 不等于「自己写样式」。它的核心是「逻辑和表现分离」。你完全可以把 headless 库包一层自己的组件,做成你们团队的设计系统,复用起来和成品组件一样方便。
2.3 为什么要 headless
有人会问:成品组件开箱即用多爽,干嘛非要自己写 markup?
几个现实原因:
- 设计需求千差万别。产品经理今天要圆角,明天要阴影,后天要暗色模式。成品组件的主题系统永远不够用,最后还是要写一堆覆盖。
- AI 时代 markup 不值钱。TanStack 官方原话:在这个 AI 能秒生成标记的时代,预构建组件「省打字」的价值越来越小,真正值钱的是掌控结果。
- 组合性强。因为你自己掌握 markup,想加虚拟化、拖拽、快捷键,都是在你自己的代码上加,不用和组件的黑盒搏斗。
- 可移植性。换 CSS 框架、换组件库,headless 的逻辑不用动,只改渲染层。
当然 headless 也有代价:上手陡,样板代码多。所以 TanStack Form 官方建议你把它包成自己的组件系统再复用,别每次都从头写。
2.4 框架无关怎么做到的
TanStack 的库大多同时支持 React、Vue、Solid、Svelte、Angular。这不是靠「在每个框架里重写一遍」实现的。
核心思路是核心逻辑 + 薄适配器:
- 大约 95% 的源码用纯 TypeScript 写,不依赖任何框架。这部分处理状态、数据、算法。
- 再为每个框架写一层薄薄的适配器,把核心逻辑和框架的响应式系统对接。
以 Table 为例,@tanstack/table-core 是纯逻辑核心,@tanstack/react-table 是 React 适配器,@tanstack/vue-table 是 Vue 适配器。核心算排序、过滤、分页,适配器负责让框架感知到状态变化并重渲染。
这样做的好处:
- 逻辑复用:核心算法只写一遍,所有框架共享
- 行为一致:React 版和 Vue 版的排序结果一模一样
- 维护成本低:修个 bug 在核心修,各框架同步生效
- bundle 友好:Table 的特性是模块化的,按需引入,只用一半特性大约只打包一半代码
Note框架无关还有个隐藏好处:迁移成本低。万一哪天你们项目从 React 迁 Vue(虽然不太可能),用 TanStack 的话,核心逻辑和概念都是通的,学习成本几乎为零。
2.5 与 UI 库解耦
headless 设计天然和 UI 库解耦。TanStack 的库不关心你用什么 CSS 方案:
- Tailwind CSS?没问题
- Material UI?没问题
- shadcn/ui?没问题
- 自己撸的 CSS?也没问题
Table 的官方文档里就有一堆和主流组件库配对的示例:shadcn(Base UI / Radix UI)、HeroUI、React Aria、Material UI、Mantine、Chakra UI,每个都有基础版和高级版。
这种解耦意味着什么?意味着你不会被绑死在某个 UI 库上。今天用 Material UI,明天想换 shadcn/ui,表格的排序逻辑、分页状态、过滤规则一行都不用改,只换渲染层的组件。
2.6 类型安全三大体现
类型安全是 TanStack 的第三个核心理念。它不是「顺便支持 TypeScript」,而是从设计之初就把类型推导当一等公民。
1. 全自动推导,少写泛型
TanStack Form 的哲学说得很直白:你不应该需要传泛型或用内部类型。一切从运行时的默认值推导。
// 不推荐:手动传泛型
useForm<MyForm>()
// 推荐:让 TS 从默认值推导
interface Person {
name: string
age: number
}
const defaultPerson: Person = { name: 'Bill Luo', age: 24 }
useForm({ defaultValues: defaultPerson })
Query 也一样,queryFn 返回什么类型,data 就是什么类型,不用你标。
2. queryOptions 类型穿透
queryOptions 是个辅助函数,运行时就是原样返回你传进去的东西,但在 TypeScript 层面它能让类型「穿透」到各个使用场景:
import { queryOptions } from '@tanstack/react-query'
function groupOptions(id: number) {
return queryOptions({
queryKey: ['groups', id],
queryFn: () => fetchGroups(id),
staleTime: 5 * 1000,
})
}
// 这些地方都能拿到正确的类型
useQuery(groupOptions(1))
queryClient.prefetchQuery(groupOptions(23))
const data = queryClient.getQueryData(groupOptions(42).queryKey)
// ^? Group[] | undefined -- 自动推导出来了
没有 queryOptions 的话,getQueryData 拿到的是 unknown,你得手动传泛型。
3. 路由类型注册
TanStack Router 把类型安全做到了极致。它用「声明合并(declaration merging)」把你的路由树类型注册到模块上:
const router = createRouter({
routeTree,
// ...
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
注册之后,Link 的 to、useParams、useSearch 这些导出的 API 全都有类型。写错路由路径,编译就报错。search params 传错了类型,也报错。
Warning这种「模块声明合并」是全局生效的。一个项目只能注册一个 router 类型。如果你在微前端场景下有多个 router,得用自定义
queryClient实例的方式隔离,v5 已经移除了旧的context方案。
2.7 三大理念的取舍
headless、框架无关、类型安全,这套设计不是没代价的:
- headless 意味着你得自己写 markup,初期样板代码多
- 框架无关 意味着核心 API 偏「数据化」,不能利用框架的专属特性(比如 React 的并发特性要靠适配器转接)
- 类型安全 意味着 TypeScript 是强依赖,不用 TS 的项目享受不到一半价值,而且大项目的类型检查会变慢
但这些都是可控的代价。headless 的样板代码可以包成组件库复用;框架无关的抽象损耗在大多数场景可以忽略;类型检查慢的问题 TanStack 也有优化建议(比如路由树用对象语法、用 as const satisfies 收窄类型)。
2.8 小结
TanStack 的三大理念是一套相互支撑的设计:
- headless 把逻辑和表现分离,给你完全控制权
- 框架无关 用「核心 + 适配器」让逻辑跨框架复用
- 类型安全 用激进推导让你少写类型、多享安全
理解了这套设计,你就能明白为什么 TanStack 的 API 长那样—比如 Table 为啥返回实例不返回组件,Query 为啥用对象签名,Router 为啥要注册类型。下一篇我们细聊类型安全这块。