文章
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 类型安全,都会清晰很多。
