TanStack Router 代码路由(Code-Based Routing)完全指南:用createRoute手工构建类型安全的路由树
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
本指南围绕 TanStack Router(本仓库的 React / Solid 全栈路由框架)的代码路由模式展开。代码路由允许你完全绕过文件系统,通过
createRootRoute/createRoute/addChildren三件套,以纯 TypeScript 代码手工声明路由树,并保持与文件路由一致的完整类型安全与匹配语义。读完本文,你将掌握代码路由的树形结构、所有路由类型(根路由、基本路由、索引路由、动态段、Splat、布局、Pathless、非嵌套路由)的代码写法,以及它们与文件路由的对应关系和适用场景。
什么是代码路由:与文件路由的同源关系
TanStack Router 支持两种声明路由树的方式:文件路由(File-Based Routing) 与代码路由。两者在底层共享完全相同的路由树概念:路由树负责把 URL 与正确的组件树进行匹配与组合(详见 Route Trees)。唯一的区别在于组织方式——文件路由用文件系统组织路由,代码路由直接用代码组织。
从源码层面看,这一“同源”关系非常直白:packages/react-router/src/fileRoute.ts中的FileRoute类在内部调用的正是createRoute工厂(见 fileRoute.ts),而代码路由直接导出并使用同一个工厂(见 index.tsx)。也就是说,文件路由本质上是在代码路由之上叠加了“文件系统 + 代码生成”的抽象层,自动生成你手工要写的那段结构。
[!TIP] 官方建议:绝大多数应用不推荐代码路由,优先使用 文件路由。代码路由的主要价值在于理解路由树的底层原理、满足极小规模或高度动态化路由的需求。
文件路由 vs 代码路由:同一个树,两种表达
以一个相同的路由树为例,文件版如下:
routes/ ├── __root.tsx ├── index.tsx ├── about.tsx ├── posts/ │ ├── index.tsx │ ├── $postId.tsx ├── posts.$postId.edit.tsx ├── settings/ │ ├── profile.tsx │ ├── notifications.tsx ├── _pathlessLayout.tsx ├── _pathlessLayout/ │ ├── route-a.tsx ├── ├── route-b.tsx ├── files/ │ ├── $.tsx对应的代码路由版本(React 与 Solid 的 API 完全一致,仅导入来源不同):
// React import { createRootRoute, createRoute } from '@tanstack/react-router' // Solid // import { createRootRoute, createRoute } from '@tanstack/solid-router' const rootRoute = createRootRoute() const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', }) const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: 'about', }) const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts', }) const postsIndexRoute = createRoute({ getParentRoute: () => postsRoute, path: '/', }) const postRoute = createRoute({ getParentRoute: () => postsRoute, path: '$postId', }) const postEditorRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts/$postId/edit', }) const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'settings', }) const profileRoute = createRoute({ getParentRoute: () => settingsRoute, path: 'profile', }) const notificationsRoute = createRoute({ getParentRoute: () => settingsRoute, path: 'notifications', }) const pathlessLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: 'pathlessLayout', }) const pathlessLayoutARoute = createRoute({ getParentRoute: () => pathlessLayoutRoute, path: 'route-a', }) const pathlessLayoutBRoute = createRoute({ getParentRoute: () => pathlessLayoutRoute, path: 'route-b', }) const filesRoute = createRoute({ getParentRoute: () => rootRoute, path: 'files/$', })上面每个createRoute调用只声明了路由的“个体”信息(父路由、路径、ID 等),完整的路由树还需要通过addChildren手工组装(见下文“手工构建路由树”一节)。
路由的解剖:createRoute与getParentRoute
除根路由外,所有代码路由都由createRoute工厂创建:
const route = createRoute({ getParentRoute: () => rootRoute, path: '/posts', component: PostsComponent, })getParentRoute是函数而非直接引用,它返回当前路由的父路由。之所以设计成回调形式,与 TanStack Router “魔法般”的类型安全密切相关:TypeScript 需要根据父路由推导出当前路由的完整路径(TFullPath)、参数(TParams)与 ID(TId)等类型。从源码可见,createRoute的泛型签名正是围绕TParentRoute递归推导的(见 route.tsx):TFullPath由ResolveFullPath<TParentRoute, TPath>解析、TId由ResolveId<TParentRoute, TCustomId, TPath>解析、TParams由ResolveParams<TPath>解析。不传入父路由,TypeScript 就无从得知该路由的完整路径、可用参数与上下文类型。
[!IMPORTANT] 除Root Route与Pathless Layout Route之外,每条路由都必须提供
path选项——这是路由与 URL pathname 进行匹配的依据。
path的规范化规则
代码路由在解析path时会忽略首尾斜杠(索引路由的特殊路径/除外)。你写不写斜杠都可以,TanStack Router 会在内部统一规范化:
| Path | Normalized Path |
|---|---|
/ | / |
/about | about |
about/ | about |
about | about |
$ | $ |
/$ | $ |
/$/ | $ |
手工构建路由树:addChildren
与文件路由不同,代码路由不会自动组装路由树——文件路由的树由 bundler 插件 / CLI 在routeTree.gen.ts中自动生成,而代码路由需要你显式把每个路由挂到父路由的children上:
/* prettier-ignore */ const routeTree = rootRoute.addChildren([ indexRoute, aboutRoute, postsRoute.addChildren([ postsIndexRoute, postRoute, ]), postEditorRoute, settingsRoute.addChildren([ profileRoute, notificationsRoute, ]), pathlessLayoutRoute.addChildren([ pathlessLayoutARoute, pathlessLayoutBRoute, ]), filesRoute.addChildren([ fileRoute, ]), ]) /* prettier-ignore-end */addChildren定义于 route.ts(根路由与普通路由均有此方法),它返回一个新的路由实例,携带完整的子路由信息,最终交给createRouter({ routeTree })使用。
代码路由中的各类路由概念
代码路由完整支持 Routing Concepts 中介绍的全部路由类型:根路由、基本路由、索引路由、动态段、Splat / Catch-All、布局路由、Pathless 路由、非嵌套路由。以下逐一给出代码写法。
根路由(Root Route)
根路由是整棵树的顶层路由,封装所有其他路由作为其子节点:它没有路径、始终被匹配、其component始终被渲染。代码路由中通过createRootRoute()创建:
// React import { createRootRoute } from '@tanstack/react-router' const rootRoute = createRootRoute()如果需要向路由注入外部上下文(如 React Query 的QueryClient),使用createRootRouteWithContext<MyRouterContext>():
import { createRootRouteWithContext } from '@tanstack/react-router' import type { QueryClient } from '@tanstack/react-query' export interface MyRouterContext { queryClient: QueryClient } const rootRoute = createRootRouteWithContext<MyRouterContext>()Solid 侧 API 完全一致,仅将导入来源换成@tanstack/solid-router与@tanstack/solid-query。该工厂在 route.tsx 中实现,返回的函数行为类似createRootRoute,但会强制约束路由上下文类型,并在创建createRouter时要求传入匹配的context(见 router.ts 的类型注释)。上下文机制的完整说明见 Router Context。
与文件路由不同,代码路由中根路由不强制导出——把整棵路由树和应用写在一个文件里虽然可行(示例中常这样做以简洁演示概念),但官方不推荐;通常仍建议把根路由单独导出,便于复用其类型与实例。
基本路由(Basic Routes)
基本路由匹配一个精确的路径段,只需在createRoute中给出普通path字符串:
const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: 'about', })该aboutRoute将匹配 URL/about。
索引路由(Index Routes)
文件路由用index文件名表示索引路由;代码路由则用单个斜杠/表示。例如路由树中的posts.index.tsx对应:
const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts', }) const postsIndexRoute = createRoute({ getParentRoute: () => postsRoute, // 注意这里的单个斜杠 `/` path: '/', })postsIndexRoute会在父路由posts被精确匹配且没有任何子路由匹配时生效,匹配 URL/posts/(或/posts)。
动态路由段(Dynamic Route Segments)
动态段在代码路由与文件路由中完全一致:给路径段加$前缀即可,捕获的值会出现在路由loader或component的params对象中:
const postIdRoute = createRoute({ getParentRoute: () => postsRoute, path: '$postId', // 在 loader 中使用 loader: ({ params }) => fetchPost(params.postId), // 或在组件中使用 component: PostComponent, }) function PostComponent() { const { postId } = postIdRoute.useParams() return <div>Post ID: {postId}</div> }例如 URL/posts/123匹配/posts/$postId时,params为{ postId: '123' }。动态段可出现在路径的每一个段上(如posts/$postId/$revisionId),每个$段都会捕获到params中。
[!TIP] 如果组件被代码分割(code-split),可以用 getRouteApi 辅助函数 在其它文件中获取类型化的
useParams(),从而避免直接导入postIdRoute配置对象。
Splat / Catch-All 路由
Splat 路由的路径只有一个$,它总是捕获从$位置到 URL 结尾的任意剩余 pathname,捕获值存放在params._splat中:
const filesRoute = createRoute({ getParentRoute: () => rootRoute, path: 'files', }) const fileRoute = createRoute({ getParentRoute: () => filesRoute, path: '$', })对于 URL/documents/hello-world(配合/files/$路由),params对象为:
{ '_splat': 'documents/hello-world' }源码中,_splat在匹配阶段被写入rawParams(见 new-process-route-tree.ts),同时为兼容旧版还写入rawParams['*'];在路径拼接阶段也同时处理_splat与*两个键(见 path.ts)。这正是文档提示“v1 中 splat 同时以*键兼容、v2 移除”的底层依据。
布局路由(Layout Routes)
布局路由通过把子路由嵌套在其下实现——父路由提供布局component,子路由通过<Outlet />渲染到布局内部:
const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts', component: PostsLayoutComponent, // 布局组件 }) function PostsLayoutComponent() { return ( <div> <h1>Posts</h1> <Outlet /> </div> ) } const postsIndexRoute = createRoute({ getParentRoute: () => postsRoute, path: '/', }) const postsCreateRoute = createRoute({ getParentRoute: () => postsRoute, path: 'create', }) const routeTree = rootRoute.addChildren([ // postsRoute 是布局路由 // 其子路由将被嵌套渲染在 PostsLayoutComponent 中 postsRoute.addChildren([postsIndexRoute, postsCreateRoute]), ])此时postsIndexRoute与postsCreateRoute都会渲染在PostsLayoutComponent内部:
// URL: /posts <PostsLayoutComponent> <PostsIndexComponent /> </PostsLayoutComponent> // URL: /posts/create <PostsLayoutComponent> <PostsCreateComponent /> </PostsLayoutComponent>布局路由的实际价值不止于组件包裹:它还可以在展示任何子路由前强制执行loader、为子路由校验并提供 search params、提供 error 组件 / pending 元素的回退、以及向所有子路由共享 context(详见 Routing Concepts 的 Layout Routes 一节)。
Pathless 布局路由(Pathless Layout Routes)
文件路由用_前缀标记 pathless 布局;代码路由中则直接使用id选项代替path——因为代码路由不使用文件系统组织路由,自然无需_前缀来声明“无路径”:
const pathlessLayoutRoute = createRoute({ getParentRoute: () => rootRoute, id: 'pathlessLayout', component: PathlessLayoutComponent, }) function PathlessLayoutComponent() { return ( <div> <h1>Pathless Layout</h1> <Outlet /> </div> ) } const pathlessLayoutARoute = createRoute({ getParentRoute: () => pathlessLayoutRoute, path: 'route-a', }) const pathlessLayoutBRoute = createRoute({ getParentRoute: () => pathlessLayoutRoute, path: 'route-b', }) const routeTree = rootRoute.addChildren([ // pathless 布局路由没有 path,只有 id // 因此其子路由会被嵌套在 pathless 布局路由之下 pathlessLayoutRoute.addChildren([pathlessLayoutARoute, pathlessLayoutBRoute]), ])此时/route-a与/route-b都会渲染在PathlessLayoutComponent内部:
// URL: /route-a <PathlessLayoutComponent> <RouteAComponent /> </PathlessLayoutComponent> // URL: /route-b <PathlessLayoutComponent> <RouteBComponent /> </PathlessLayoutComponent>需要注意的是:Pathless 布局路由不基于 URL 路径段匹配,因此其自身路径不能包含动态段(_$postId/这种写法不合法),动态段必须放在 pathless 布局的父级(如$postId/下的_postPathlessLayout/),参见 Routing Concepts。
非嵌套路由(Non-Nested Routes)
文件路由用posts_.$postId.edit.tsx中父段后缀_表示“不嵌套”;代码路由则不需要任何特殊后缀,只需把完整路径写进path选项,并将该路由挂到你想让它嵌套的位置(此处是根路由)即可:
// 帖子编辑器路由嵌套在根路由之下 const postEditorRoute = createRoute({ getParentRoute: () => rootRoute, // path 包含需要匹配的完整路径 path: 'posts/$postId/edit', }) const postsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'posts', }) const postRoute = createRoute({ getParentRoute: () => postsRoute, path: '$postId', }) const routeTree = rootRoute.addChildren([ // 帖子编辑器路由直接挂在根路由下 postEditorRoute, postsRoute.addChildren([postRoute]), ])这样,URL/posts/123/edit将渲染独立的<PostEditor>组件树,而不是嵌套在<Posts>之内;而/posts/123仍渲染<Posts><Post postId="123">。
匹配顺序:代码路由同样遵循的排序规则
无论路由树如何声明,Route Matching 中定义的排序规则对代码路由同样生效:TanStack Router 会自动把所有路由按特异性排序,先匹配最具体的路由:
- 索引路由(Index Route)
- 静态路由(Static Routes,最具体到最不具体)
- 动态路由(Dynamic Routes,最长到最短)
- Splat / 通配路由(Splat/Wildcard Routes)
这意味着代码路由的声明顺序不影响最终匹配结果——例如即使你把$postId写在about之前,/about也依然命中静态路由about而非动态路由。这一点与文件路由完全一致,因为两者在底层共享同一套路由匹配引擎(见 new-process-route-tree.ts 中的匹配与排序实现)。
小结:何时使用代码路由
| 维度 | 文件路由 | 代码路由 |
|---|---|---|
| 组织方式 | 文件系统 + 代码生成 | 纯代码(createRoute+addChildren) |
| 路由树组装 | 插件 / CLI 自动生成routeTree.gen.ts | 手工调用addChildren |
| 索引路由 | index文件名 | path: '/' |
| Pathless 布局 | _前缀 | id选项 |
| 非嵌套路由 | 父段后缀_ | 把完整路径写入path |
| 类型安全 | 自动生成类型链接 | 依赖getParentRoute递归推导 |
| 适用场景 | 大多数应用(推荐) | 极小规模、高度动态、或学习路由树原理 |
代码路由的完整 API 可以在仓库源码中进一步研读:createRoute 工厂实现、createRootRoute / createRootRouteWithContext、addChildren 定义、以及路由匹配与参数捕获引擎 new-process-route-tree.ts。若你希望保留文件路由的便捷性又需要更高的定制自由,也可以参考 Virtual File Routes 这一折中方案。
【免费下载链接】router🤖 A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考