在 Storybook 中渲染 TanStack Router 嵌套路由树:route、path 与 routeOverrides 实战指南
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
本篇技术指南聚焦 Storybook 官方 TanStack React 框架(@storybook/tanstack-react)的核心能力之一——将应用中嵌套的路由树完整搬进 Storybook 的画布。通过parameters.tanstack.router下的route、path、routeOverrides三个参数,你可以让某个深层路由(例如位于认证外壳内的设置页)在 Storybook 中连同其全部父级布局一起渲染,并用一行配置“焊死”祖先路由上的守卫与数据加载逻辑。读完本文,你将掌握嵌套路由 Story 的标准写法(CSF 3 与 CSF Next 两种风格)、每个参数的底层行为,以及框架复制路由树时的内部原理。
一、为什么需要“路由树 Story”
在真实的 TanStack Router 应用中,页面很少是孤立的路由:/settings/profile往往挂在一棵多层的路由树下——
__root__ └── /_authenticated (无路径布局:认证外壳,负责登录态检查) └── /settings └── /profile (真正要展示的页面)如果直接把Profile组件塞进 Storybook,父级布局提供的 UI 骨架(侧边栏、导航、权限上下文)就会缺失;而如果引入完整应用,又会拖入整个运行时。@storybook/tanstack-react的解决方案是:从你指定的路由出发,向上遍历到根路由,把整棵路由树复制一份到内存路由器中,于是父级布局照常渲染,Story 又能独立运行。
在动手前,请确认项目满足框架的使用前提:基于 React(≥ 18)与 Vite(≥ 7)构建,且安装了@tanstack/react-router。
二、核心配置:三行参数渲染整棵嵌套路由树
以下写法直接来自官方文档示例 tanstack-react-route-tree-story.md,是渲染嵌套路由的标准姿势。
CSF 3 写法
// SettingsProfile.stories.ts import type { Meta, StoryObj } from '@storybook/tanstack-react'; // 👇 Route 文件本身就是应用路由树的一部分 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 = {};CSF Next 写法
// SettingsProfile.stories.ts import preview from '../.storybook/preview'; // 👇 Route 文件本身就是应用路由树的一部分 import { Route } from './routes/_authenticated/settings/profile'; const meta = preview.meta({ parameters: { tanstack: { router: { // 👇 Storybook 向上遍历到根并复制整棵路由树, // 因此父级布局(例如认证外壳)也会一并渲染。 route: Route, path: '/settings/profile', // 👇 屏蔽父级路由的守卫,让 Story 可以独立渲染。 routeOverrides: { '/_authenticated': { beforeLoad: () => {} }, }, }, }, }, }); export const Default = meta.story();两种写法只是 Storybook 元数据 API 不同,parameters.tanstack.router的对象结构完全一致。三个关键参数分工明确:
| 参数 | 类型 | 作用 |
|---|---|---|
route | AnyRoute \| route options 对象 | 指定要渲染的路由实例;框架会自动提取其 React 组件,并保留类型化的路由配置 |
path | string | 设置 Story 路由器的初始 URL 路径,用于在树中定位具体路由 |
routeOverrides | Partial<Record<string, RouteOverrideOptions>> | 按路由 ID 覆盖树中任意路由的选项(如beforeLoad、loader),无需修改原始路由对象 |
类型定义见框架源码 routing/types.ts:routeOverrides的键是路由 ID(例如'/about'、'/demo/form/simple/$id',以及特殊键'__root__')。
三、源码原理:Storybook 如何“复制整棵路由树”
示例注释里那句“walks up the tree to root and duplicates the full route tree”并非虚言,其实现集中在 decorator.tsx 的resolveTree与 duplicate-tree.ts 的duplicateRouteTree中。
1. 找到根路由(findRootRoute)
框架先通过findRootRoute(resolvedRoute)沿getParentRoute()向上遍历(上限 50 跳以防环),直到命中RootRoute实例(duplicate-tree.ts)。如果传入的route本身就是routeTree.gen.ts导出的整棵RouteTree,它会被直接当作根使用。
2. 整树复制(duplicateRouteTree)
复制过程不是浅拷贝,而是递归重建每一个节点(duplicate-tree.ts):
- 先用
initSourceTree对源树逐节点init(),确保派生属性(如id、fullPath)填充完整; - 对每个子路由用
createRoute(而非createFileRoute)重建,避免在全局文件路由注册表中产生重复注册,从而规避多 Story 同时挂载时 TanStack 抛出的Duplicate routeIds found: __root__; - 复制过程中同步套用
routeOverrides:cloneChild里读取原路由的options,合并对应 override 后生成克隆(duplicate-tree.ts); - 根路由也总是新建:
id与getParentRoute被剥离,shellComponent(TanStack Start 的html/head/body文档外壳)被特意丢弃——因为它无法嵌套进 Story 画布,且其 head 内容会劫持页面标题;其余根行为(component、notFoundComponent、errorComponent、beforeLoad上下文)全部保留; - 克隆节点通过
byId: Map以原始路由 ID索引,这是routeOverrides能按应用中的 ID 定位克隆的关键。
3. 定位并注入 Story(resolveStoryLeaf+injectStoryComponent)
复制完成后,resolveStoryLeaf依据path(可先经params插值)、绑定路由 ID 等顺序在克隆树中定位叶子节点(duplicate-tree.ts),injectStoryComponent再把<Story />注入该叶子的component(decorator.tsx)。最终由createStoryRouter基于createMemoryHistory构建内存路由器(decorator.tsx),并通过RouterProvider包裹渲染——整个流程零网络请求、零应用 shell 启动。
从源码结构还可以推断:由于每个 Story 都复制一份独立路由树,Story 之间的路由状态天然隔离,这也是框架“每个 Story 拥有独立路由器上下文”的架构基础。
四、routeOverrides 详解:可覆盖哪些路由选项
routeOverrides是嵌套路由场景的“安全阀”,其可覆盖字段由RouteOverrideOptions接口定义(types.ts):
| 可覆盖选项 | 说明 |
|---|---|
component | 覆盖路由组件 |
loader | 覆盖数据加载函数(常用于替换真实 API 调用) |
beforeLoad | 覆盖进入路由前的守卫/鉴权逻辑(本文示例正是用它清空/_authenticated的守卫) |
validateSearch | 覆盖搜索参数校验 |
loaderDeps | 覆盖 loader 依赖 |
context | 覆盖路由上下文 |
配套示例 tanstack-react-route-tree-overrides.md 展示了典型用法:用routeOverrides替换loader,让 Story 不调用真实接口:
// UserCard.stories.ts import type { Meta, StoryObj } from '@storybook/tanstack-react'; import { Route } from './UserCard'; 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>; export default meta; type Story = StoryObj<typeof meta>; export const Default: Story = {};注意两个细节:
- 键必须是原始路由 ID。克隆节点自身的
idgetter 基于init()填充,在注入阶段还未就绪,因此框架通过byId映射回原始 ID 才能命中 override(相关回归测试见 duplicate-tree.test.ts)。 '__root__'可定位根路由。当根路由的beforeLoad注入全局上下文时,用routeOverrides: { '__root__': { beforeLoad: () => ({ ... }) } }即可替换(decorator.tsx 与 types.ts 均给出了该键的用法)。
另外,源码在合并 override 时对无路径布局(pathless layout,显式id而无path)做了特殊处理:即使 override 给这种布局新增了path,也不会触发 TanStack 的 id+path 不变式冲突(duplicate-tree.test.ts 有对应验证)。
五、延伸场景:动态参数与无路径布局
动态路由参数(/$id)
当目标路由带动态段时,用params把参数插值进 URL,同时用routeOverrides替换 loader(示例见 tanstack-react-dynamic-params.md):
import { Route } from './$id'; const meta = { parameters: { tanstack: { router: { route: Route, params: { id: '42' }, routeOverrides: { '/showcase/$id': { loader: () => ({ item: mockItem }), }, }, }, }, }, } satisfies Meta<typeof Route>;params的类型会被约束为该路由声明中的参数名(例如/$id对应{ id: string },见 types.ts),插值逻辑与路径解析在createStoryRouter中通过 TanStack 的interpolatePath完成(decorator.tsx)。
直接挂载无路径布局
如果你把 Story 直接绑定在一个无路径布局(如_authed/index.tsx)上,框架的ensureMatchableLeaf会复用该布局已有的 index 子路由(path: '/')或合成一个,让布局可被匹配且路径推断落到真实 URL(decorator.tsx)。相关回归测试覆盖了“pathless 布局嵌套在 pathful 祖先下”“复用已有 index 子路由”等场景(decorator.test.ts)。
六、验证与调试建议
- 源码单元测试是理解行为的最佳教材:duplicate-tree.test.ts 覆盖了 pathless 布局克隆、
routeOverrides按原始 ID 生效、lazy 路由绑定迁移、参数透出等;decorator.test.ts 验证了createStoryRouter的路径推断与 override 注入。 - 排查问题时可用
router.state.matches观察最终匹配的路由 ID 链(测试中即用此断言'_authed'等布局被命中),确认父级布局是否真的进入了渲染链。 - 框架的其余参数(
query搜索参数、context/useRouterContext路由上下文注入等)见框架文档 tanstack-react.mdx 的 Parameters 一节,可与本文的路由树配置组合使用。
小结
渲染嵌套路由树是@storybook/tanstack-react的核心工作流:route提供入口,path定位叶子,routeOverrides按原始路由 ID 逐个屏蔽守卫与替换加载逻辑;框架底层则通过duplicateRouteTree递归复制整棵树并用内存路由器挂载,从而在完全不启动应用 shell 的前提下还原真实的嵌套渲染层级。掌握了这套配置与原理,你就能为任意深度的 TanStack Router 页面编写独立、可复现的 Story。
【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考