Essay
React Router v7 源码解析:routes.ts 的加载与解析机制
React Router v7 的 Framework Mode 把 Remix 的很多能力合并进了 React Router 本体。在这种模式下,app/routes.ts 是路由配置的核心入口:它不是普通的运行时 JSX 路由树,而是会在开发和构建阶段被读取、执行、验证,并转换成内部使用的 Route Manifest。
这也是 Framework Mode 能提供类型生成、自动代码分割、服务端渲染、数据加载约定和 route module 类型推导的基础。
下面从源码角度梳理 routes.ts 从用户配置文件到路由清单的完整流程。
官方写法先看一眼
官方文档推荐在 app/routes.ts 中从 @react-router/dev/routes 导入辅助函数,并用 satisfies RouteConfig 保留类型检查:
import {
type RouteConfig,
index,
layout,
prefix,
route,
} from "@react-router/dev/routes";
export default [
index("./home.tsx"),
route("about", "./about.tsx"),
layout("./auth/layout.tsx", [
route("login", "./auth/login.tsx"),
route("register", "./auth/register.tsx"),
]),
...prefix("concerts", [
index("./concerts/home.tsx"),
route(":city", "./concerts/city.tsx"),
route("trending", "./concerts/trending.tsx"),
]),
] satisfies RouteConfig;
每个 route entry 本质上包含两类信息:
- URL pattern,例如
about、teams/:teamId、files/*。 - route module 文件路径,例如
./about.tsx。
这些 route module 再分别导出 loader、action、默认组件、ErrorBoundary、headers 等功能。
整体架构
简化以后,routes.ts 的处理链路大概是:
app/routes.ts
↓ vite-node 执行 TypeScript/ESM 配置
RouteConfigEntry[]
↓ validateRouteConfig 校验
合法的 RouteConfig
↓ 包装到 app/root.tsx 下面
包含 root 的配置树
↓ configRoutesToRouteManifest
扁平化 RouteManifest
↓ Vite 插件、server build、类型生成等后续流程使用
相关模块主要包括:
| 模块 | 作用 |
|---|---|
@react-router/dev/config/routes.ts |
定义 RouteConfigEntry、RouteManifestEntry、RouteConfig 以及 route、index、layout、prefix、relative 等辅助函数 |
@react-router/dev/config/config.ts |
加载 react-router.config.ts、查找并执行 routes.ts、生成 resolved config |
@react-router/dev/vite/vite-node.ts |
创建 vite-node 上下文,用于执行 TypeScript / ESM 配置文件 |
@react-router/dev/vite/plugin.ts |
Vite 插件,把路由配置接入 dev server、构建、虚拟模块和 manifest 生成流程 |
核心类型:RouteConfigEntry 与 RouteManifestEntry
用户在 routes.ts 中写的是嵌套结构,大致对应 RouteConfigEntry:
export interface RouteConfigEntry {
id?: string;
path?: string;
index?: boolean;
caseSensitive?: boolean;
file: string;
children?: RouteConfigEntry[];
}
export type RouteConfig = RouteConfigEntry[] | Promise<RouteConfigEntry[]>;
它适合人类阅读和维护,因为嵌套关系可以直接写在配置里。
但内部构建更常用的是扁平化映射,也就是 RouteManifest:
export interface RouteManifest {
[routeId: string]: RouteManifestEntry;
}
export interface RouteManifestEntry {
id: string;
parentId?: string;
file: string;
path?: string;
index?: boolean;
caseSensitive?: boolean;
}
这个结构更适合后续生成 server build、client manifest、类型文件和模块引用。
vite-node:为什么可以直接执行 TypeScript 配置
routes.ts 通常是 TypeScript + ESM 文件,不能简单用 Node.js 直接 require。
React Router dev tooling 会创建一个 vite-node 上下文,用 Vite 的转换能力来执行这些配置文件。这样用户可以在配置中使用:
- TypeScript
- ESM import/export
- Vite 解析逻辑
- 动态
await - 来自
@react-router/dev/routes或@react-router/fs-routes的辅助函数
这里有一个容易误解的点:routes.ts 不是被浏览器运行的,也不是应用运行时每次请求时解析的。它属于 开发/构建期配置,会被 dev server 和 build pipeline 读取。
配置加载流程
核心逻辑可以简化成这样:
async function resolveConfig(...) {
// 1. 加载 react-router.config.ts
let userConfig = await loadReactRouterConfig();
// 2. 查找 app/routes.ts、routes.js、routes.mts 等入口
let routeConfigFile = findEntry(appDirectory, "routes");
// 3. 设置 appDirectory,供 fs-routes / relative helper 使用
setAppDirectory(appDirectory);
// 4. 用 vite-node 执行 routes.ts 的 default export
let routeConfigExport = (
await viteNodeContext.runner.executeFile(routeConfigFile)
).default;
// 5. 支持异步 RouteConfig
let routeConfig = await routeConfigExport;
// 6. 验证配置结构
let result = validateRouteConfig({
routeConfigFile,
routeConfig,
});
if (!result.valid) {
return err(result.message);
}
// 7. 包装到 root route 下面
let routesWithRoot = [{
id: "root",
path: "",
file: "root.tsx",
children: result.routeConfig,
}];
// 8. 转换为 Route Manifest
let routes = configRoutesToRouteManifest(appDirectory, routesWithRoot);
return ok({ routes, ... });
}
这里最关键的是:用户写的 routes.ts 会被执行,得到一个 RouteConfigEntry[] 或 Promise<RouteConfigEntry[]>。所以官方示例里可以写:
import { type RouteConfig, route } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default [
route("/", "./home.tsx"),
...(await flatRoutes()),
] satisfies RouteConfig;
这说明 routes.ts 并不局限于手写配置,也可以和文件路由约定混合。
路由配置验证
React Router 使用 valibot 对 route config 做 schema 校验。
它会检查:
- 默认导出是否存在。
- 导出结果是否为数组。
- 每个 entry 是否是对象,而不是未
await的 Promise。 id是否为字符串。id不能使用保留值root。path是否为字符串。index和caseSensitive是否为布尔值。file是否为字符串。children是否为合法的 route config entry 数组。
例如,如果你在 children 中误放了一个 Promise,错误信息会提示:
Invalid type: Expected object but received a promise. Did you forget to await?
如果没有默认导出,会得到类似:
Route config must be the default export in "routes.ts".
这一步很重要,因为后续的 Vite 插件和类型生成都依赖一个结构稳定的 route config。
树形结构如何变成扁平 Route Manifest
configRoutesToRouteManifest 做的事情并不复杂:递归遍历 route config tree,为每个 route 生成一个 route id,并记录它的 parentId。
简化版逻辑如下:
export function configRoutesToRouteManifest(
appDirectory: string,
routes: RouteConfigEntry[],
): RouteManifest {
let routeManifest: RouteManifest = {};
function walk(route: RouteConfigEntry, parentId?: string) {
let id = route.id || createRouteId(route.file);
let manifestItem = {
id,
parentId,
file: Path.isAbsolute(route.file)
? Path.relative(appDirectory, route.file)
: route.file,
path: route.path,
index: route.index,
caseSensitive: route.caseSensitive,
};
if (routeManifest.hasOwnProperty(id)) {
throw new Error(
`Unable to define routes with duplicate route id: "${id}"`,
);
}
routeManifest[id] = manifestItem;
for (let child of route.children ?? []) {
walk(child, id);
}
}
for (let route of routes) {
walk(route);
}
return routeManifest;
}
默认 route id 来自 file,并去掉扩展名。例如:
./routes/about.tsx → routes/about
./routes/posts.$id.tsx → routes/posts.$id
这也是为什么两个 route 不能指向同一个默认 id,除非你显式提供不同的 id。
辅助函数:route / index / layout / prefix / relative
React Router 提供了一组 helper,目的是让 routes.ts 写起来更简洁。
route
route("about", "./about.tsx")
表示一个有 URL path 的普通路由。
也可以带 children:
route("dashboard", "./dashboard.tsx", [
index("./dashboard-home.tsx"),
route("settings", "./dashboard-settings.tsx"),
])
index
index("./home.tsx")
表示 index route,会渲染在父路由的 <Outlet /> 中,URL 就是父路由自己的 URL。
index route 不能有 children。
layout
layout("./marketing/layout.tsx", [
index("./marketing/home.tsx"),
route("contact", "./marketing/contact.tsx"),
])
layout route 会新增嵌套层级,但不会给 URL 增加 path segment。它适合做共享布局、认证壳、页面框架等。
prefix
...prefix("projects", [
index("./projects/home.tsx"),
route(":pid", "./projects/project.tsx"),
])
prefix 只是在一组 routes 的 path 前加前缀,不会引入新的 route 节点。
也就是说:
prefix("parent", [
route("child1", "./child1.tsx"),
route("child2", "./child2.tsx"),
])
大致等价于:
[
route("parent/child1", "./child1.tsx"),
route("parent/child2", "./child2.tsx"),
]
relative
源码里还有一个容易被忽略的 helper:relative(directory)。
它会返回 scoped 版本的 route、index、layout,让文件路径相对于某个目录解析。这对大型项目拆分 route config 很有用,例如:
import { relative } from "@react-router/dev/routes";
const admin = relative("./admin");
export default [
admin.layout("./layout.tsx", [
admin.index("./dashboard.tsx"),
admin.route("users", "./users.tsx"),
]),
];
它解决的问题是:当你把某一组路由拆到一个目录里维护时,不需要在每个 route module 路径前反复写目录前缀。
root route 的特殊性
官方文档明确说明:routes.ts 中的所有路由都会被嵌套在特殊的 app/root.tsx 下面。
这就是为什么用户不能把 route id 设置成 root:root 是框架保留给根路由的 id。
最终 manifest 里通常会包含:
{
"root": {
id: "root",
file: "root.tsx",
path: "",
},
"routes/about": {
id: "routes/about",
parentId: "root",
file: "routes/about.tsx",
path: "about",
},
}
root.tsx 承担的是应用壳层角色,通常会包含 <Outlet />、<Scripts />、<Links />、<Meta />、<ScrollRestoration /> 等框架组件。
Vite 插件如何使用 Route Manifest
React Router 的 Vite 插件会把 resolved config 放入插件上下文。这个 config 里包含前面生成的 routes manifest。
后续构建中,插件会基于 manifest 生成或处理:
- server build 入口。
- client build manifest。
- route module imports。
- route module 到 chunk 的映射。
- 类型生成需要的 route 信息。
- 开发时 HMR 和配置变更刷新。
可以把 routes.ts → Route Manifest 理解成 React Router Framework Mode 的“路由编译前端”。后面的 SSR、代码分割、数据加载、类型安全都依赖这个中间表示。
Watch 模式与重新加载
开发时,React Router dev tooling 会监听相关配置文件和 app 目录变化。一旦 routes.ts、react-router.config.ts 或 route module 发生变化,插件需要重新加载配置,并让 Vite dev server 看到新的 route manifest。
这解释了为什么修改 routes.ts 往往会触发比普通组件更大的刷新:它改变的不是单个 React 组件,而是整个路由拓扑。
几个容易踩的点
1. 忘记 await flatRoutes()
如果你混合使用 @react-router/fs-routes:
export default [
...(flatRoutes()),
] satisfies RouteConfig;
这是错的,因为 flatRoutes() 返回 Promise。应该写成:
export default [
...(await flatRoutes()),
] satisfies RouteConfig;
否则校验阶段会看到 Promise,而不是 route config entry。
2. route id 冲突
如果两个 route module 默认生成同一个 id,configRoutesToRouteManifest 会报 duplicate route id。
解决方式是调整文件路径,或者显式传入不同的 id。
3. index route 不能有 children
index() 表示父路径的默认子路由。它没有自己的 path segment,因此不能再挂 children。
4. prefix 不会创建父 route
prefix("admin", [...]) 只是批量改 path,不会生成一个 admin layout route。需要 layout 时应该显式使用:
route("admin", "./admin/layout.tsx", [
route("users", "./admin/users.tsx"),
])
或者使用无 path 的:
layout("./admin/layout.tsx", [
...prefix("admin", [
route("users", "./admin/users.tsx"),
]),
])
小结
routes.ts 看起来只是一个普通配置文件,但在 React Router v7 Framework Mode 里,它实际上是整个框架编译链路的入口之一。
它完成了几件关键事情:
- 用 TypeScript/ESM 表达路由拓扑。
- 通过 helper 生成标准
RouteConfigEntry。 - 支持异步配置和文件路由约定混合。
- 通过
valibot做结构校验。 - 自动嵌套到
app/root.tsx下面。 - 转换成扁平化
RouteManifest。 - 供 Vite 插件、SSR 构建、client manifest 和类型生成使用。
理解这条链路之后,再看 React Router v7 的自动代码分割、modulepreload、SSR server build、route module 类型安全,都会清晰很多。
