首页 / TanStack 生态入门教程 / headless 理念与框架无关设计

TanStack 生态入门教程

headless 理念与框架无关设计

本教程共 38 篇 · 第 2 篇 · 更新于 2026-07-27 · 约 8 分钟阅读

TanStackTanStack 生态入门教程headless框架无关类型安全UI 库适配器设计理念

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?没问题。

Tip

headless 不等于「自己写样式」。它的核心是「逻辑和表现分离」。你完全可以把 headless 库包一层自己的组件,做成你们团队的设计系统,复用起来和成品组件一样方便。

2.3 为什么要 headless

有人会问:成品组件开箱即用多爽,干嘛非要自己写 markup?

几个现实原因:

  1. 设计需求千差万别。产品经理今天要圆角,明天要阴影,后天要暗色模式。成品组件的主题系统永远不够用,最后还是要写一堆覆盖。
  2. AI 时代 markup 不值钱。TanStack 官方原话:在这个 AI 能秒生成标记的时代,预构建组件「省打字」的价值越来越小,真正值钱的是掌控结果。
  3. 组合性强。因为你自己掌握 markup,想加虚拟化、拖拽、快捷键,都是在你自己的代码上加,不用和组件的黑盒搏斗。
  4. 可移植性。换 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
  }
}

注册之后,LinktouseParamsuseSearch 这些导出的 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 为啥要注册类型。下一篇我们细聊类型安全这块。