Storybook 集成 TanStack React 框架:从 @storybook/react-vite 迁移到 @storybook/tanstack-react 的完整指南
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
在 Storybook 官方框架体系中,@storybook/tanstack-react是专为基于 TanStack Router 与 TanStack Start 构建的 React + Vite 应用设计的框架集成。本文以在.storybook/main.ts中将框架从@storybook/react-vite切换为@storybook/tanstack-react为核心操作,系统讲解安装步骤、CSF 3 与 CSF Next 两种配置写法、自动化迁移工具,以及迁移后如何利用路由感知渲染、自动 Mock 与 TanStack Query 集成能力,帮助你在 Storybook 中无需启动完整应用即可独立开发、测试和文档化依赖路由与服务端函数(Server Functions)的组件。
一、为什么需要 @storybook/tanstack-react
@storybook/tanstack-react是 Storybook 针对 TanStack Router 和 TanStack Start 应用提供的框架集成,它建立在@storybook/react-vite(参见 docs/get-started/frameworks/react-vite.mdx)之上,额外带来三层核心能力(来源:docs/get-started/frameworks/tanstack-react.mdx):
- 路由感知渲染:自动用内存路由(Memory-backed Router)包裹每个 Story,提供可用的 Router 上下文,无需启动完整应用外壳;
- 自动 Router Mock:将
@tanstack/react-router的导入重定向到 Storybook 兼容的 Mock 层,useNavigate、useSearch、useParams等 Hook 在 Story 中照常可用,导航行为可被观测; - TanStack Start Mock:自动桩替换 Start 服务端与运行时入口点,使依赖 Server Functions 的组件可以直接在 Storybook 中渲染。
从仓库源码看,该框架位于 code/frameworks/tanstack-react,其 package.json 中明确声明依赖@storybook/builder-vite、@storybook/react、@storybook/react-vite,并在 peerDependencies 中要求@tanstack/react-router、@tanstack/react-start(可选)、@tanstack/router-core、@tanstack/start-client-core(可选)以及 React 与 Vite(参见 package.json)。
二、环境要求与安装
环境要求
官方文档(docs/get-started/frameworks/tanstack-react.mdx)给出的最低要求为:
- React:≥ 18
- Vite:≥ 7
同时要求项目中已存在 TanStack Router 应用(@tanstack/react-router可用);如果应用使用了 TanStack Start 的 API(如 Server Functions),还需要保留对应的 TanStack Start 包。从源码的 peerDependencies 看,该框架实际声明的范围更宽(Vite^5 || ^6 || ^7 || ^8,React^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0),但建议以官方文档的 React ≥ 18、Vite ≥ 7 作为稳妥基线。
安装框架包
在项目根目录执行安装(来源:docs/_snippets/tanstack-react-install.md):
npm install --save-dev @storybook/tanstack-reactpnpm add --save-dev @storybook/tanstack-reactyarn add --dev @storybook/tanstack-react如果你是全新项目,也可以直接使用 Storybook 的创建命令初始化(见 docs/get-started/frameworks/tanstack-react.mdx 中的create命令),然后从编写 Story、运行测试、编写文档开始。
三、核心操作:在 .storybook/main.ts 中切换框架
将@storybook/react-vite切换为@storybook/tanstack-react的核心操作是修改.storybook/main.ts中的framework属性。根据你的配置写法,有两种形式(来源:docs/_snippets/tanstack-react-add-framework.md)。
形式一:CSF 3(经典配置对象)
- import type { StorybookConfig } from '@storybook/react-vite'; + import type { StorybookConfig } from '@storybook/tanstack-react'; const config: StorybookConfig = { // ... - framework: '@storybook/react-vite', + framework: '@storybook/tanstack-react', }; export default config;形式二:CSF Next(defineMain 工厂函数)
- import { defineMain } from '@storybook/react-vite/node'; + import { defineMain } from '@storybook/tanstack-react/node'; export default defineMain({ // ... - framework: '@storybook/react-vite', + framework: '@storybook/tanstack-react', });注意两个细节:
类型导入同步切换:CSF 3 场景下,
StorybookConfig类型要从@storybook/tanstack-react导入;CSF Next 场景下,defineMain要从@storybook/tanstack-react/node导入。这与框架包的导出结构一一对应——从 package.json 的exports字段可以看到,包根路径导出主入口,./node导出 Node 侧入口(供 CSF 工厂使用),此外还暴露了./preset、./preview、./react-router、./start、./start-storage-context等子路径。framework 属性可以携带 options:当你的
.storybook/main.ts需要向 Vite 构建器传参时,可把framework写成对象形式(来源:docs/_snippets/tanstack-react-framework-options.md):
import type { StorybookConfig } from '@storybook/tanstack-react'; const config: StorybookConfig = { framework: { name: '@storybook/tanstack-react', options: { builder: { // Vite builder options }, }, }, }; export default config;import { defineMain } from '@storybook/tanstack-react/node'; export default defineMain({ framework: { name: '@storybook/tanstack-react', options: { builder: { // Vite builder options }, }, }, });其中options.builder的类型为Record<string, any>,用于配置框架底层的 Vite 构建器(详见 docs/builders/vite.mdx 与 docs/api/main-config/main-config-framework.mdx)。在源码层面,corepreset 会把该builder选项透传给@storybook/builder-vite(见 src/preset.ts)。
四、同步更新 preview 与类型引用
切换框架后,.storybook/preview.*中的类型导入也需要同步更新,保证类型检查一致(来源:docs/_snippets/tanstack-react-preview-migrate.md):
- import type { Preview } from '@storybook/react-vite'; + import type { Preview } from '@storybook/tanstack-react'; const preview: Preview = { //... }; export default preview;- import { definePreview } from '@storybook/react-vite'; + import { definePreview } from '@storybook/tanstack-react'; export default definePreview({ //... });同时,你的 Story 文件(*.stories.*)中Meta、StoryObj类型也应从@storybook/tanstack-react导入,以启用parameters.tanstack.router的类型安全(下文会看到具体示例)。
五、自动化迁移:npx storybook automigrate
除了手工修改,Storybook 还提供了自动化迁移工具:
npx storybook automigrate react-vite-to-tanstack-react根据官方文档(docs/get-started/frameworks/tanstack-react.mdx),该工具依次执行:
- 更新
package.json,将@storybook/react-vite替换为@storybook/tanstack-react; - 更新
.storybook/main.js|ts中的 framework 属性(同时兼容普通配置与 CSF 工厂defineMain配置); - 扫描并更新所有引用
@storybook/react-vite的 import 语句(包括 CSF 工厂使用的@storybook/react-vite/node),覆盖 Story 文件与 Storybook 配置文件; - 检测
.storybook/preview.*、其余.storybook/目录及所有*.stories.*中的手动 TanStack Router 装饰器,发现后会提供可复制的 AI 提示词,引导 AI 助手删除已冗余的装饰器。
迁移后的清理:删除手动 Router 装饰器
这一点需要特别强调:@storybook/tanstack-react已经自动为每个 Story 包裹 TanStack Router,因此迁移后任何手写的RouterProvider/createRouter/createMemoryHistory/createRootRoute装饰器都应删除。需要指定路由的 Story,请改用parameters.tanstack.router(见下一节)而不是手写装饰器。
六、迁移后能做什么:parameters.tanstack.router 全参数指南
切换框架后,你可以在 Story 的parameters.tanstack.router命名空间下声明路由行为。以下是该框架贡献的全部参数(来源:docs/get-started/frameworks/tanstack-react.mdx 的 API 章节)。
| 参数 | 类型 | 说明 |
|---|---|---|
route | AnyRoute \| route options object | 直接传入一个 Route 实例,或用一个包含path等选项的对象创建一个临时 Story 路由;Storybook 会自动从路由中提取 React 组件 |
path | string | 设置 Story 路由的初始 URL 路径(也支持携带#fragment) |
query | Record<string, unknown> | 向初始 URL 追加搜索参数(如?tab=details&page=2) |
params | ResolveParams<Path> | 将路由参数插值到当前路径;当route是类型化文件路由时,类型会被约束为该路由路径声明的参数名(例如/$id对应{ id: string }) |
routeOverrides | Partial<Record<string, RouteOverrideOptions>> | 按路由 ID 覆盖路由选项,作用于 Story 路由与根路由;用'__root__'定位根路由,可覆盖loader、beforeLoad、validateSearch、loaderDeps、context |
context | Record<string, unknown> \| (({ storyContext }) => Record<string, unknown>) | 注入 Story 路由的路由上下文;支持静态对象或接收 storyContext 的工厂函数,工厂在路由初始加载之前、React 渲染之外运行,因此其值可被loader与beforeLoad读取 |
useRouterContext | ({ storyContext }) => RouterContext | 在渲染期间以 React Hook 方式计算路由上下文,适合读取只能从 React Provider 获取的值(如useQueryClient()) |
6.1 渲染一个 Route
将 TanStack Route 对象通过parameters.tanstack.router.route提供给 Story(来源:docs/_snippets/tanstack-react-route-story.md):
import type { Meta, StoryObj } from '@storybook/tanstack-react'; import { Route } from './Page'; const meta = { parameters: { layout: 'fullscreen', tanstack: { router: { route: Route, // 👈 在这里提供 Route // 👇 其余属性均为类型安全 params: { id: '42' }, query: { tab: 'details' }, }, }, }, } satisfies Meta<typeof Route>; export default meta; type Story = StoryObj<typeof meta>; export const Default: Story = {}; export const WithCustomLoader: Story = { parameters: { tanstack: { router: { route: Route, // 👈 在这里提供 Route params: { id: '42' }, routeOverrides: { '/items/$id': { loader: async () => ({ item: { id: '42', name: 'Loaded inside Storybook' }, }), }, }, }, }, }, };6.2 处理动态参数(如 /$id)
params对象会被插值进 URL,routeOverrides则让你在不改动原始路由对象的前提下桩替换 loader(来源:docs/_snippets/tanstack-react-dynamic-params.md):
import type { Meta } from '@storybook/tanstack-react'; import { Route } from './$id'; const meta = { parameters: { tanstack: { router: { route: Route, params: { id: '42' }, routeOverrides: { '/showcase/$id': { loader: () => ({ item: mockItem }), }, }, }, }, }, } satisfies Meta<typeof Route>; export default meta;6.3 渲染嵌套路由
当route是连接到应用路由树(route tree)的文件路由时,Storybook 会自动向上回溯到根并复制整条路由树,父级布局路由(如_authenticated认证外壳)也会随之渲染。你也可以直接传入routeTree.gen.ts导出的routeTree(来源:docs/_snippets/tanstack-react-route-tree-story.md):
import type { Meta, StoryObj } from '@storybook/tanstack-react'; // 👇 路由文件是应用路由树的一部分 import { Route } from './routes/_authenticated/settings/profile'; const meta = { parameters: { tanstack: { router: { // 👇 Storybook 向上回溯到根并复制整条路由树, // 因此父级布局(如认证外壳)也会渲染 route: Route, path: '/settings/profile', // 👇 桩替换父级路由的守卫,让 Story 可以独立渲染 routeOverrides: { '/_authenticated': { beforeLoad: () => {} }, }, }, }, }, } satisfies Meta<typeof Route>; export default meta; type Story = StoryObj<typeof meta>; export const Default: Story = {};6.4 对普通组件使用路由参数
如果 Story 渲染的是普通 React 组件而非路由对象,仍可通过parameters.tanstack.router提供路由上下文——组件可以正常读取useRouterState、useSearch、useParams、useLoaderData等 Hook(来源:docs/_snippets/tanstack-react-plain-component-story.md):
import type { Meta, StoryObj } from '@storybook/tanstack-react'; import { Page } from './Page'; const meta = { component: Page, } satisfies Meta<typeof Page>; export default meta; type Story = StoryObj<typeof meta>; export const Default: Story = { parameters: { tanstack: { router: { route: { path: '/demo/form/address', }, query: { view: 'list' }, }, }, }, };6.5 定义搜索参数与 URL 片段(hash)
用query定义搜索参数(如?tab=details&page=2),用path定义 URL 片段(如#section-name)(来源:docs/_snippets/tanstack-react-query-and-path.md):
export const WithHash: Story = { parameters: { tanstack: { // 👇 为路由提供 URL 片段(hash) router: { path: '/#section-name' }, }, }, }; export const WithSearch: Story = { parameters: { tanstack: { // 👇 为路由提供查询字符串 router: { query: { tab: 'details', page: '2' } }, }, }, };6.6 按 Story 覆盖路由选项
当路由的loader或beforeLoad会调用真实 API 时,可以在不修改原始路由对象的前提下,通过routeOverrides按 Story 覆盖。每个 key 是一个路由 ID,值可覆盖loader、beforeLoad、validateSearch、loaderDeps、context;用'__root__'定位根路由(来源:docs/_snippets/tanstack-react-route-tree-overrides.md):
const meta = { title: 'Users/UserCard', parameters: { tanstack: { router: { route: Route, params: { userId: '42' }, // 👇 覆盖路由的 loader,让 Story 不调用真实 API routeOverrides: { '/users/$userId': { loader: async () => ({ user: { id: '42', name: 'Ada Lovelace' } }), }, }, }, }, }, } satisfies Meta<typeof Route>;七、底层原理:自动 Mock 是如何实现的
从源码看,该框架的自动 Mock 能力由 src/preset.ts 中的viteFinal统一装配。它在@storybook/react-vite的 Vite 配置基础上追加了三个自定义插件:
- moduleInterceptionPlugin(src/plugins/module-interception.ts):在模块解析阶段,把
@tanstack/react-router(及其子路径)导入重定向到@storybook/tanstack-react/react-routerMock 模块;同时拦截@tanstack/react-start、@tanstack/react-start/server、@tanstack/react-start-server、@tanstack/start-server-core等 Start 服务端模块以及virtual:cloudflare、server-entry、worker-entry等虚拟模块,防止它们进入浏览器; - serverCodeEliminationPlugin(src/plugins/server-code-elimination.ts):做服务端代码消除;
- serverOnlyStubPlugin(src/plugins/server-only-stub.ts):桩替换服务端专用模块。
此外,preset 还通过previewAnnotations注入了@storybook/tanstack-react/preview(src/preview.tsx),并通过optimizeViteDeps预优化了@tanstack/react-router的依赖链。
对外,框架包暴露了./react-router与./start两个子路径(见 package.json 与 src/export-mocks),分别提供 TanStack Router 与 TanStack Start 兼容的 Mock 实现(包括 Mock 化的createServerFn())。测试中如需断言导航 Spy,可显式从这些模块导入。
八、在 Story 中 Mock Server Functions
如果组件导入了 TanStack Start 的 Server Function,Storybook 会把createServerFn().handler(...)的结果变成 Mock 函数,你可以用标准 Mock API 按 Story 覆盖,从而无需改动应用代码即可展示加载、成功、失败等状态(来源:docs/_snippets/tanstack-react-mock-server-fn-stories.md):
import type { Meta, StoryObj } from '@storybook/tanstack-react'; import { expect, mocked } from 'storybook/test'; import { updateProfile } from '../lib/updateProfile'; import { ProfileForm } from './ProfileForm'; const meta = { component: ProfileForm, } satisfies Meta<typeof ProfileForm>; export default meta; type Story = StoryObj<typeof meta>; export const Success: Story = { beforeEach: async () => { mocked(updateProfile).mockResolvedValue({ ok: true, name: 'Ada Lovelace' }); }, play: async ({ canvas, userEvent }) => { await userEvent.type(canvas.getByLabelText('Name'), 'Ada Lovelace'); await userEvent.click(canvas.getByRole('button', { name: 'Save profile' })); await expect(updateProfile).toHaveBeenCalled(); }, }; export const Failure: Story = { beforeEach: async () => { mocked(updateProfile).mockRejectedValue(new Error('Could not save profile')); }, };九、处理 Server-only 依赖(三层策略)
TanStack Start 应用常在路由文件的模块作用域内导入服务端专用包(如数据库客户端、认证库),这些包在浏览器中会崩溃。该框架分三层处理:
第一层:框架级 Mock(自动)。preset 已拦截@tanstack/react-start、@tanstack/react-start/server、@tanstack/start-storage-context等模块,并把createServerFn()处理器替换为 Mock 函数,无需你做任何事。
第二层:应用级服务端模块。当路由导入了应用自己的服务端代码(如~/db/client、~/auth/index.server)时,需要用__mocks__文件阻止真实模块及其 Node.js 依赖加载进浏览器。
第 1 步,在.storybook/preview.ts注册 Mock(来源:docs/_snippets/tanstack-react-mock-module-preview.md):
import { sb } from 'storybook/test'; // 阻止 postgres(仅限 Node)加载进浏览器 sb.mock(import('../src/db/client.ts')); export default {};第 2 步,在真实模块旁创建src/db/__mocks__/client.ts,只使用import type,避免引入任何服务端包。
这里要解释清楚为什么用__mocks__文件而不是自动 Mock:Storybook 的自动 Mock 只替换函数,但仍会求值原始模块及其导入。对于导入了postgres、pg等 Node.js 专属包的模块,原始模块绝不能被求值,否则浏览器会崩溃;__mocks__文件是唯一能完全阻止原始模块及其依赖链求值的方案(详见 docs/writing-stories/mocking-data-and-modules/mocking-modules.mdx)。
第三层:识别需要 Mock 的模块。报错如does not provide an export named 'default'或AsyncLocalStorage is not defined,说明服务端专用模块进入了浏览器。修复方法是 Mock服务端模块本身,而不是使用它的组件或路由。例如Dashboard.tsx导入~/auth/session,~/auth/session导入~/db/client,~/db/client导入postgres——就 Mock~/db/client。Node.js 依赖(postgres)是"冒烟枪",Mock 离它最近、且由你掌控的模块即可。
两种不需要 Mock 的情况:
- 模块来自
@tanstack/*——框架 preset 已处理,请确保@storybook/tanstack-react是最新版本; - 模块只导入了
createServerFn——已被 Mock,报错来自同一文件中的其他导入。
十、与 TanStack Query 协同使用
该框架可与 TanStack Query 配合,在 Storybook 中提供可用的 QueryClient 并按 Story 预置查询数据。TanStack Query 不会被自动配置,官方推荐方案是:在 preview 文件中创建单一QueryClient,通过beforeEach在 Story 之间清空缓存,并让同一个实例同时进入parameters.tanstack.router.context和QueryClientProvider装饰器(来源:docs/_snippets/tanstack-react-query-setup.md):
import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { Preview } from '@storybook/tanstack-react'; // 👇 创建新的 QueryClient const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity, }, }, }); const preview: Preview = { beforeEach: () => { // 👇 在 Story 之间清空缓存,让每个 Story 从全新状态开始 queryClient.clear(); }, parameters: { tanstack: { router: { // 👇 让 queryClient 通过 ctx.context.queryClient 在 Story 的 beforeEach 中可用 context: { queryClient }, }, }, }, decorators: [ (Story) => ( // 👇 向所有 Story 提供 QueryClient <QueryClientProvider client={queryClient}> <Story /> </QueryClientProvider> ), ], }; export default preview;这样无论 Storybook 以何种方式渲染 Story(侧边栏、Docs 页、portable stories 或测试运行),路由上下文与 React Provider 都指向同一个 QueryClient;每个 Story 在渲染前清空缓存,获得全新的查询状态。
在单个 Story 中,用beforeEach对共享 QueryClient 调用setQueryData预置数据,通过parameters.tanstack.router.context取出实例(来源:docs/_snippets/tanstack-react-query-in-story.md):
export const LoggedIn: Story = { beforeEach: async ({ parameters }) => { const qc: QueryClient = parameters.tanstack?.router?.context?.queryClient; qc?.setQueryData(['currentUser'], { id: 'user-1', name: 'Ada Lovelace', }); }, };如果需要更强的隔离(例如在同一个 Docs 页面上渲染多个使用相同 query key 的 Story 并保持各自缓存独立),也可以为每个 Story 创建独立的 QueryClient,但必须让路由上下文与QueryClientProvider指向同一个实例,并显式清理每个客户端持有的定时器、订阅与缓存数据。
十一、FAQ 与常见问题
何时用@storybook/tanstack-react而非@storybook/react-vite?当组件依赖 TanStack Router 或 TanStack Start 的 API,且需要 Storybook 提供路由上下文、类型化路由参数、自动 Router Mock 与 Mock 化的 Start Server Function 行为时使用前者(参见 docs/get-started/frameworks/react-vite.mdx);标准 React + Vite 且不使用 TanStack Router 的项目继续使用@storybook/react-vite。
样式在 Storybook 中丢失怎么办?在.storybook/preview.*中导入应用 CSS,使其随 preview 一起打包:
import '../src/styles/app.css';详见 docs/configure/styling-and-css.mdx。
如何为所有 Story 提供 React Context Provider(主题、toast、认证等)?使用项目级装饰器(docs/writing-stories/decorators.mdx 中的全局装饰器)为所有 Story 提供 Provider;也可以在组件级或 Story 级装饰器中按需提供。
支持 React Server Components 吗?不支持。该框架使用内存路由在浏览器中运行 Story,而 React Server Components 需要服务端运行时。如果组件是 Server Component,请将客户端部分提取为 Client Component 再编写 Story。
报错"模块未提供 default 导出"?通常是服务端专用模块被导入进了浏览器。按第九节的流程沿错误堆栈定位模块并添加 Storybook Mock。
十二、小结
从@storybook/react-vite切换到@storybook/tanstack-react本质上是三步:安装框架包、修改.storybook/main.ts的framework属性(CSF 3 与 CSF Next 两种写法)、同步preview.*与 Story 文件的类型导入。之后你便获得了内存路由包裹、路由感知渲染、parameters.tanstack.router全参数控制、TanStack Router/Start 自动 Mock、Server-only 依赖三层治理以及与 TanStack Query 的无缝协同——这些能力在源码层由 preset.ts 中的 Vite 插件链(模块拦截、服务端代码消除、服务端专用模块桩替换)支撑实现。仓库还提供了可直接参考的框架内置示例 Story(见 template/stories,包含Outlet、PathlessLayout、RouterContextInjection、LoaderContextInjection等场景),可以作为上手与对照的最佳实践模板。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考