代码式路由
本教程共 38 篇 · 第 17 篇 · 更新于 2026-07-27 · 约 12 分钟阅读
17. 代码式路由
本节目标:学会用
createRoute手动构建路由树,搞懂getParentRoute、path、id三要素的关系,了解虚拟文件路由把文件式和代码式混用。学完你能在不能用文件式路由的场景下手动搭路由,也能在文件式里局部用代码式。
17.1 什么时候用代码式路由
上一章讲了文件式路由,官方推荐,省心省力。那为啥还要学代码式?
Note官方原话:代码式路由不推荐用于大多数应用,建议用文件式路由。但有些场景代码式更合适。
代码式路由适合这些场景:
- 老项目接入:已有自己的目录组织方式,不想按 TanStack 的命名约定重组文件。
- 路由完全程序化:路由结构要从配置文件、后端接口动态生成,不是写死的。
- 特殊组织需求:要把路由定义和组件放一起,或者按业务模块拆分得非常细。
- 学习原理:理解代码式能更深入理解文件式背后在干啥(文件式本质是生成代码式代码)。
文件式路由其实是代码式路由的超集—它用文件命名约定加代码生成,自动产出代码式路由代码。理解了代码式,文件式的很多行为就通了。
17.2 代码式路由的核心三要素
代码式路由用 createRoute 创建每个路由,三个关键选项要搞清:
getParentRoute:函数,返回这个路由的父路由。必填,类型安全靠它。path:路由路径(除了根路由和无路径布局路由)。匹配 URL 用。id:路由的唯一标识。无路径布局路由用id代替path。
先看最简单的根路由和基础路由:
import { createRootRoute, createRoute } from '@tanstack/react-router'
// 根路由:不传 path
const rootRoute = createRootRoute()
// 基础路由:传 path,getParentRoute 指向 rootRoute
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'about',
})
Warning每个非根、非无路径布局的路由都必须有
path。getParentRoute是函数(返回路由),不是路由本身。写成getParentRoute: rootRoute会报错,得写成getParentRoute: () => rootRoute。
17.2.1 path 的规范化
path 写不写斜杠都行,TanStack Router 内部会规范化:
| 你写的 path | 规范化后 |
|---|---|
/ | /(索引路由特殊保留) |
/about | about |
about/ | about |
about | about |
$ | $(通配) |
/$ | $ |
所以 path: 'about'、path: '/about'、path: 'about/' 效果一样。但索引路由的 path: '/' 不能省,那个斜杠是索引的标志。
17.3 各种路由用代码怎么写
第 15 章讲过路由类型,这里看每种类型用代码式怎么实现。和文件式对照着看更清楚。
17.3.1 根路由
和文件式一样,调 createRootRoute():
import { createRootRoute, Outlet } from '@tanstack/react-router'
const rootRoute = createRootRoute({
component: () => <div><Outlet /></div>,
})
要上下文用 createRootRouteWithContext<T>():
import { createRootRouteWithContext } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
interface MyRouterContext {
queryClient: QueryClient
}
const rootRoute = createRootRouteWithContext<MyRouterContext>()({
component: () => <div><Outlet /></div>,
})
第 21 章讲 Router 和 Query 集成时会用到这个带上下文的根路由。
17.3.2 索引路由
文件式用 index 文件名标记,代码式用 path: '/':
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts',
})
const postsIndexRoute = createRoute({
getParentRoute: () => postsRoute,
path: '/', // 索引路由,匹配 /posts 精确访问
})
postsIndexRoute 匹配 /posts 或 /posts/。
17.3.3 动态路由
$ 前缀的段是动态参数,和文件式一样:
const postRoute = createRoute({
getParentRoute: () => postsRoute,
path: '$postId',
loader: ({ params }) => fetchPost(params.postId),
component: PostComponent,
})
function PostComponent() {
const { postId } = postRoute.useParams()
return <div>文章 ID:{postId}</div>
}
$ 能出现在任意段,/posts/$postId/$revisionId 也行。
Tip如果组件被代码分割(懒加载),不能直接 import
postRoute。这时用getRouteApi帮手在别的文件里拿到类型安全的useParams,第 18 章会讲。
17.3.4 通配路由
path: '$' 是通配,匹配从这到结尾的所有内容:
const filesRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'files',
})
const fileRoute = createRoute({
getParentRoute: () => filesRoute,
path: '$',
})
访问 /files/documents/hello-world,params._splat 是 'documents/hello-world'。
17.3.5 布局路由
代码式里,“有子路由的路由”就是布局路由。给父路由配 component,组件里放 <Outlet />,子路由挂上去就自动套布局:
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts',
component: PostsLayoutComponent,
})
function PostsLayoutComponent() {
return (
<div>
<h1>文章区</h1>
<Outlet />
</div>
)
}
const postsIndexRoute = createRoute({
getParentRoute: () => postsRoute,
path: '/',
})
const postsCreateRoute = createRoute({
getParentRoute: () => postsRoute,
path: 'create',
})
访问 /posts 渲染 <PostsLayoutComponent><PostsIndex /></PostsLayoutComponent>,访问 /posts/create 渲染 <PostsLayoutComponent><PostsCreate /></PostsLayoutComponent>。
17.3.6 无路径布局路由
文件式用 _ 前缀,代码式用 id 代替 path:
const pathlessLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'pathlessLayout', // 用 id 而不是 path
component: PathlessLayoutComponent,
})
function PathlessLayoutComponent() {
return (
<div>
<h1>无路径布局</h1>
<Outlet />
</div>
)
}
const pathlessARoute = createRoute({
getParentRoute: () => pathlessLayoutRoute,
path: 'route-a',
})
const pathlessBRoute = createRoute({
getParentRoute: () => pathlessLayoutRoute,
path: 'route-b',
})
id 是路由的唯一标识,因为无路径路由不参与 URL 匹配,得有个 ID 让 TypeScript 区分。访问 /route-a 渲染 <PathlessLayout><RouteA /></PathlessLayout>,URL 里没 pathlessLayout。
17.3.7 非嵌套路由
文件式用 _ 后缀脱离父级,代码式不用特殊语法,靠怎么挂路由树和完整路径实现:
// 文章编辑路由,直接挂根路由下,path 写完整
const postEditorRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts/$postId/edit', // 完整路径
})
const postsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'posts',
})
const postRoute = createRoute({
getParentRoute: () => postsRoute,
path: '$postId',
})
const routeTree = rootRoute.addChildren([
postEditorRoute, // 挂根下,不套 <Posts>
postsRoute.addChildren([postRoute]), // 套 <Posts>
])
postEditorRoute 的 path 是 'posts/$postId/edit'(完整路径),getParentRoute 是 rootRoute,所以它匹配 /posts/123/edit 但不套 <Posts> 组件。这种”URL 上属于某组但布局独立”的需求,代码式很自然就能表达。
17.4 手动构建路由树
代码式路由不像文件式那样自动生成路由树,得自己用 addChildren 拼:
const routeTree = rootRoute.addChildren([
indexRoute,
aboutRoute,
postsRoute.addChildren([
postsIndexRoute,
postRoute,
]),
postEditorRoute,
settingsRoute.addChildren([
profileRoute,
notificationsRoute,
]),
pathlessLayoutRoute.addChildren([
pathlessARoute,
pathlessBRoute,
]),
filesRoute.addChildren([fileRoute]),
])
addChildren 接收一个数组,数组元素可以是路由,也可以是”路由.addChildren([…])”这种带子路由的路由。层层嵌套下去就是路由树。
Warning
addChildren不会自动把路由的父子关系对齐。你定义postRoute时写了getParentRoute: () => postsRoute,但路由树里忘了把postRoute放进postsRoute.addChildren([...]),类型会出错。两边的父子关系要一致。
17.5 创建 Router 并注册类型
路由树拼好后,创建 Router 实例:
import { createRouter } from '@tanstack/react-router'
const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
// 注册类型,让 TypeScript 认识你的路由
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
declare module 这段和文件式一样,不能漏。注册后 <Link to="/posts"> 才能检查路由是否存在。
挂载到应用:
import { RouterProvider } from '@tanstack/react-router'
import ReactDOM from 'react-dom/client'
ReactDOM.createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />,
)
17.6 虚拟文件路由:两种方式混用
有时候你既想用文件式的省心,又想在某些地方用代码式的灵活。TanStack Router 提供了虚拟文件路由(Virtual File Routes),让你用代码描述路由树,但路由组件还是引用真实文件。
适用场景:
- 已有路由组织方式,想保留。
- 想自定义路由文件位置。
- 想完全覆盖文件式路由生成,搞自己的约定。
17.6.1 安装和配置
虚拟文件路由用 @tanstack/virtual-file-routes 包。配 Vite 插件时传 virtualRouteConfig:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { tanstackRouter } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
virtualRouteConfig: './routes.ts', // 指向虚拟路由配置文件
}),
react(),
],
})
17.6.2 用工具函数描述路由树
在 routes.ts 里用 rootRoute、route、index、layout、physical 这些函数描述路由树,每个函数引用一个真实文件:
import {
rootRoute,
route,
index,
layout,
physical,
} from '@tanstack/virtual-file-routes'
export const routes = rootRoute('root.tsx', [
index('index.tsx'),
layout('pathlessLayout.tsx', [
route('/dashboard', 'app/dashboard.tsx', [
index('app/dashboard-index.tsx'),
route('/invoices', 'app/dashboard-invoices.tsx', [
index('app/invoices-index.tsx'),
route('$id', 'app/invoice-detail.tsx'),
]),
]),
physical('/posts', 'posts'), // 挂载一个文件式目录
]),
])
各函数作用:
rootRoute(file, children):虚拟根路由,指向根组件文件。route(path, file, children?):普通路由,path是 URL 路径,file是组件文件。能省略file只设路径前缀。index(file):索引路由。layout(file, children, id?):无路径布局路由,可选自定义id。physical(path, dir):把一个文件式路由目录挂到指定路径下。
17.6.3 physical:在虚拟路由里嵌入文件式
physical 是虚拟文件路由最香的地方。你的大部分路由用文件式(省心),少数特殊地方用虚拟路由自定义,两者能共存:
export const routes = rootRoute('root.tsx', [
index('index.tsx'),
layout('pathlessLayout.tsx', [
route('/dashboard', 'app/dashboard.tsx', [
index('app/dashboard-index.tsx'),
]),
// posts 目录用文件式,挂到 /posts 下
physical('/posts', 'posts'),
]),
])
posts 目录下按文件式命名约定放文件(index.tsx、$postId.tsx 等),自动生成 /posts、/posts/$postId 这些路由。外面用虚拟路由自定义结构,里面用文件式省心。
Tip
physical还能不带路径前缀,把一个目录的路由合并到当前层级:physical('features')把features/目录的路由并到当前层,不加 URL 前缀。适合按业务模块分目录但 URL 平级的情况。
17.6.4 在文件式里嵌入虚拟路由
反过来也行:主体用文件式,某个子目录用虚拟路由。在那个目录里放个 __virtual.ts 文件,里面用 defineVirtualSubtreeConfig 描述子树:
routes/
├── __root.tsx
├── foo/
│ ├── bar/
│ │ ├── __virtual.ts # 这个目录用虚拟路由
│ │ ├── details.tsx
│ │ ├── home.tsx
│ │ └── route.ts
│ └── bar.tsx
└── index.tsx
import { defineVirtualSubtreeConfig, index, route } from '@tanstack/virtual-file-routes'
export default defineVirtualSubtreeConfig([
index('home.tsx'),
route('$id', 'details.tsx'),
])
defineVirtualSubtreeConfig 里不用 rootRoute(因为不是根),只描述这个子树的结构。子树里还能再嵌套文件式目录,甚至再嵌套虚拟路由,套娃无限。
17.7 代码式 vs 文件式:怎么选
看张对比表:
| 维度 | 文件式 | 代码式 |
|---|---|---|
| 配置方式 | 文件命名约定 | 手写 createRoute |
| 路由树生成 | 插件自动 | 手动 addChildren |
| 代码量 | 少 | 多 |
| 类型安全 | 强(自动生成类型链路) | 强(但手写易漏) |
| 灵活性 | 受命名约定约束 | 完全自由 |
| 适合场景 | 新项目、标准结构 | 老项目、动态路由、特殊组织 |
| 自动代码分割 | 默认支持 | 需手动配置 |
| 学习成本 | 命名规则要记 | 概念直接但要写更多代码 |
选择建议:
- 新项目、没有特殊需求:用文件式,省心省力,官方推荐。
- 老项目接入、目录结构不想改:用代码式或虚拟文件路由。
- 路由结构动态生成(比如从后端配置读):用代码式。
- 主体用文件式但局部要自定义:用虚拟文件路由的
__virtual.ts子树。 - 主体用自定义结构但部分用文件式:用虚拟文件路由的
physical挂载。
Note不管选哪种,核心概念(路由树、路由匹配、loader、搜索参数)都是一样的。区别只在”路由树怎么搭”。后面章节讲的内容两种方式都适用。
17.8 常见坑
坑一:getParentRoute 写成路由本身。 要写函数 () => rootRoute,不能写 rootRoute。写成路由本身类型推导会出错。
坑二:addChildren 和 getParentRoute 不一致。 getParentRoute 写的父路由,和 addChildren 里挂的父路由必须一致。不一致类型会报错。
坑三:无路径布局路由忘了用 id。 无路径布局路由不能用 path,得用 id。id 是唯一标识,名字不能和其他路由重复。
坑四:非嵌套路由路径写不完整。 postEditorRoute 要脱离 posts 嵌套,path 必须写完整的 'posts/$postId/edit',不能只写 'edit'。只写 'edit' 就变成 posts 的子路由了。
坑五:代码式忘了代码分割。 文件式默认自动代码分割,代码式不会。要代码分割得用 React.lazy 配合路由的 component 选项手动配置。
坑六:虚拟文件路由的 route 路径里下划线当字面量。 文件式里 _ 前缀有特殊含义(无路径布局),但虚拟路由的 route('/about', 'about.tsx') 里路径是字面量,_ 就是普通字符。要无路径布局用 layout 函数,别在 route 路径里用 _。
17.9 小结
代码式路由的内容理一遍:
- 用
createRoute创建路由,三要素:getParentRoute、path、id。 - 各种路由类型的代码写法:根、基础、索引、动态、通配、布局、无路径布局、非嵌套。
- 路由树用
addChildren手动拼,父子关系要和getParentRoute一致。 - 虚拟文件路由用
@tanstack/virtual-file-routes,在文件式和代码式之间找平衡:physical在虚拟路由里嵌文件式,__virtual.ts在文件式里嵌虚拟路由。 - 选择上:新项目用文件式,老项目或特殊需求用代码式或虚拟文件路由。
代码式路由虽然不推荐做主力,但理解它能让你更深入掌握 TanStack Router 的工作原理,也能在特殊场景下灵活应对。下一章讲类型安全导航和 Link 组件,这是 TanStack Router 类型安全最直观的体现。