# React Router v7 源码解析：routes.ts 的加载与解析机制

- **HTML:** <https://blog.asuk.app/zh/p/react-router-v7-routes-parsing>
- **Language:** `zh`
- **Author:** Asuka
- **Published:** `2026-01-08T00:00:00.000Z`
- **Updated:** `2026-10-13T10:42:36.000Z`
- **Tags:** React Router, Vite, Source Code, TypeScript

> 深入分析 React Router v7 Framework 模式下 routes.ts 文件的加载、验证和转换流程，理解 RouteConfig、Route Manifest、辅助函数、fs-routes 混用及 Vite 插件集成。

## Content

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` 保留类型检查：

```ts
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 本质上包含两类信息：

1. URL pattern，例如 `about`、`teams/:teamId`、`files/*`。
2. route module 文件路径，例如 `./about.tsx`。

这些 route module 再分别导出 `loader`、`action`、默认组件、`ErrorBoundary`、`headers` 等功能。


## 整体架构

简化以后，`routes.ts` 的处理链路大概是：

```text
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`：

```ts
export interface RouteConfigEntry {
  id?: string;
  path?: string;
  index?: boolean;
  caseSensitive?: boolean;
  file: string;
  children?: RouteConfigEntry[];
}

export type RouteConfig = RouteConfigEntry[] | Promise<RouteConfigEntry[]>;
```

它适合人类阅读和维护，因为嵌套关系可以直接写在配置里。

但内部构建更常用的是扁平化映射，也就是 `RouteManifest`：

```ts
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 读取。


## 配置加载流程

核心逻辑可以简化成这样：

```ts
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[]>`。所以官方示例里可以写：

```ts
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，错误信息会提示：

```text
Invalid type: Expected object but received a promise. Did you forget to await?
```

如果没有默认导出，会得到类似：

```text
Route config must be the default export in "routes.ts".
```

这一步很重要，因为后续的 Vite 插件和类型生成都依赖一个结构稳定的 route config。


## 树形结构如何变成扁平 Route Manifest

`configRoutesToRouteManifest` 做的事情并不复杂：递归遍历 route config tree，为每个 route 生成一个 route id，并记录它的 `parentId`。

简化版逻辑如下：

```ts
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`，并去掉扩展名。例如：

```text
./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

```ts
route("about", "./about.tsx")
```

表示一个有 URL path 的普通路由。

也可以带 children：

```ts
route("dashboard", "./dashboard.tsx", [
  index("./dashboard-home.tsx"),
  route("settings", "./dashboard-settings.tsx"),
])
```


### index

```ts
index("./home.tsx")
```

表示 index route，会渲染在父路由的 `<Outlet />` 中，URL 就是父路由自己的 URL。

index route 不能有 children。


### layout

```ts
layout("./marketing/layout.tsx", [
  index("./marketing/home.tsx"),
  route("contact", "./marketing/contact.tsx"),
])
```

layout route 会新增嵌套层级，但不会给 URL 增加 path segment。它适合做共享布局、认证壳、页面框架等。


### prefix

```ts
...prefix("projects", [
  index("./projects/home.tsx"),
  route(":pid", "./projects/project.tsx"),
])
```

`prefix` 只是在一组 routes 的 path 前加前缀，不会引入新的 route 节点。

也就是说：

```ts
prefix("parent", [
  route("child1", "./child1.tsx"),
  route("child2", "./child2.tsx"),
])
```

大致等价于：

```ts
[
  route("parent/child1", "./child1.tsx"),
  route("parent/child2", "./child2.tsx"),
]
```


### relative

源码里还有一个容易被忽略的 helper：`relative(directory)`。

它会返回 scoped 版本的 `route`、`index`、`layout`，让文件路径相对于某个目录解析。这对大型项目拆分 route config 很有用，例如：

```ts
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 里通常会包含：

```ts
{
  "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`：

```ts
export default [
  ...(flatRoutes()),
] satisfies RouteConfig;
```

这是错的，因为 `flatRoutes()` 返回 Promise。应该写成：

```ts
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 时应该显式使用：

```ts
route("admin", "./admin/layout.tsx", [
  route("users", "./admin/users.tsx"),
])
```

或者使用无 path 的：

```ts
layout("./admin/layout.tsx", [
  ...prefix("admin", [
    route("users", "./admin/users.tsx"),
  ]),
])
```


## 小结

`routes.ts` 看起来只是一个普通配置文件，但在 React Router v7 Framework Mode 里，它实际上是整个框架编译链路的入口之一。

它完成了几件关键事情：

1. 用 TypeScript/ESM 表达路由拓扑。
2. 通过 helper 生成标准 `RouteConfigEntry`。
3. 支持异步配置和文件路由约定混合。
4. 通过 `valibot` 做结构校验。
5. 自动嵌套到 `app/root.tsx` 下面。
6. 转换成扁平化 `RouteManifest`。
7. 供 Vite 插件、SSR 构建、client manifest 和类型生成使用。

理解这条链路之后，再看 React Router v7 的自动代码分割、modulepreload、SSR server build、route module 类型安全，都会清晰很多。


## 参考

- [React Router Framework Routing](https://reactrouter.com/start/framework/routing)
- [React Router routes.ts convention](https://reactrouter.com/api/framework-conventions/routes.ts)
- [React Router source: packages/react-router-dev/config/routes.ts](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/config/routes.ts)
