文章

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

作者:Asuka

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,例如 aboutteams/:teamIdfiles/*
  2. route module 文件路径,例如 ./about.tsx

这些 route module 再分别导出 loaderaction、默认组件、ErrorBoundaryheaders 等功能。

整体架构#

简化以后,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 定义 RouteConfigEntryRouteManifestEntryRouteConfig 以及 routeindexlayoutprefixrelative 等辅助函数
@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 是否为字符串。
  • indexcaseSensitive 是否为布尔值。
  • 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 版本的 routeindexlayout,让文件路径相对于某个目录解析。这对大型项目拆分 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 设置成 rootroot 是框架保留给根路由的 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.tsreact-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 类型安全,都会清晰很多。

参考#

This browser prefers English.

You can switch the site to English and save that choice for future visits.