React Router v7 源码解析:routes.ts 的加载与解析机制Natalie Kinnear / Unsplash

Essay

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

By Asuka

React Router v7 引入了 Framework 模式,其中 routes.ts 文件是路由配置的核心。与传统的运行时路由定义不同,routes.ts 在构建时被解析,这使得类型生成、代码分割等高级功能成为可能。

下面从源码角度分析 routes.ts 从文件到路由清单(Route Manifest)的完整流程。

整体架构#

整个流程从 routes.ts 出发,经过 vite-node 执行、validateRouteConfig 验证、configRoutesToRouteManifest 转换,最终生成 ResolvedReactRouterConfig

核心模块如下:

模块 作用
@react-router/dev/config/routes.ts 定义 RouteConfigEntry 类型和辅助函数
@react-router/dev/config/config.ts 配置加载器,加载 react-router.config.tsroutes.ts
@react-router/dev/vite/vite-node.ts 创建 vite-node 上下文,用于执行 TypeScript 配置文件
@react-router/dev/vite/plugin.ts Vite 插件,将路由配置集成到构建流程

核心类型定义#

路由配置的核心类型定义在 config/routes.ts 中:

TypeScript
// packages/react-router-dev/config/routes.ts

export interface RouteConfigEntry {
  id?: string;           // 路由唯一标识
  path?: string;         // URL 路径模式
  index?: boolean;       // 是否为 index 路由
  caseSensitive?: boolean;
  file: string;          // 路由模块文件路径
  children?: RouteConfigEntry[];  // 嵌套路由
}

// routes.ts 的导出类型
export type RouteConfig = RouteConfigEntry[] | Promise<RouteConfigEntry[]>;

RouteConfigEntry 是用户在 routes.ts 中定义的嵌套树形结构。而内部使用的 RouteManifest 则是扁平化的映射:

TypeScript
export interface RouteManifest {
  [routeId: string]: RouteManifestEntry;
}

export interface RouteManifestEntry {
  id: string;
  parentId?: string;
  file: string;
  path?: string;
  index?: boolean;
  caseSensitive?: boolean;
}

vite-node:执行 TypeScript 配置文件#

React Router 使用 vite-node 来执行 routes.ts,这样用户可以在配置文件中使用 TypeScript 和 ESM 语法,无需预编译。

TypeScript
// packages/react-router-dev/vite/vite-node.ts

export async function createContext({ root, mode, customLogger }): Promise<Context> {
  // 创建一个最小化的 Vite dev server 用于转译
  const devServer = await vite.createServer({
    root,
    mode,
    customLogger,
    server: {
      preTransformRequests: false,
      hmr: false,
      watch: null,
    },
    configFile: false,
    plugins: [],
  });

  // 创建 vite-node server 和 runner
  const server = new ViteNodeServer(devServer);
  const runner = new ViteNodeRunner({
    root: devServer.config.root,
    base: devServer.config.base,
    fetchModule: (id) => server.fetchModule(id),
    resolveId: (id, importer) => server.resolveId(id, importer),
  });

  return { devServer, server, runner };
}

vite-node 的关键优势:

  • 按需转译:只转译实际导入的模块,而非整个项目。
  • 完整的 Vite 生态:支持所有 Vite 插件和转换。
  • 隔离执行:配置代码在独立环境中执行,不影响主构建。

配置加载流程#

配置加载的核心逻辑在 config/config.tsresolveConfig 函数中:

TypeScript
// packages/react-router-dev/config/config.ts

async function resolveConfig({
  root,
  viteNodeContext,
  reactRouterConfigFile,
  skipRoutes,
}): Promise<ConfigResult> {
  // 1. 加载 react-router.config.ts
  let reactRouterUserConfig = {};
  if (reactRouterConfigFile) {
    let configModule = await viteNodeContext.runner.executeFile(reactRouterConfigFile);
    reactRouterUserConfig = configModule.default;
  }

  // 2. 查找 routes.ts 文件
  let routeConfigFile = findEntry(appDirectory, "routes");

  // 3. 设置全局 app 目录(供路由辅助函数使用)
  setAppDirectory(appDirectory);

  // 4. 使用 vite-node 执行 routes.ts
  let routeConfigExport = (
    await viteNodeContext.runner.executeFile(
      Path.join(appDirectory, routeConfigFile),
    )
  ).default;

  // 5. 验证路由配置(支持 async)
  let result = validateRouteConfig({
    routeConfigFile,
    routeConfig: await routeConfigExport,
  });

  if (!result.valid) {
    return err(result.message);
  }

  // 6. 包装在 root 路由下
  let routeConfig = [{
    id: "root",
    path: "",
    file: Path.relative(appDirectory, rootRouteFile),
    children: result.routeConfig,
  }];

  // 7. 转换为扁平化的 RouteManifest
  let routes = configRoutesToRouteManifest(appDirectory, routeConfig);

  return ok({ routes, ... });
}

findEntry 函数会查找支持的文件扩展名:

TypeScript
const entryExts = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts"];

function findEntry(dir: string, basename: string): string | undefined {
  for (let ext of entryExts) {
    let file = Path.resolve(dir, basename + ext);
    if (fs.existsSync(file)) return file;
  }
  return undefined;
}

路由配置验证#

验证使用 valibot 进行 schema 检查,提供清晰的错误信息:

TypeScript
// packages/react-router-dev/config/routes.ts

export const routeConfigEntrySchema = v.pipe(
  // 检查是否误传了 Promise
  v.custom((value) => {
    return !(
      typeof value === "object" &&
      value !== null &&
      "then" in value &&
      "catch" in value
    );
  }, "Invalid type: Expected object but received a promise. Did you forget to await?"),

  v.object({
    id: v.optional(
      v.pipe(
        v.string(),
        v.notValue("root", "A route cannot use the reserved id 'root'."),
      ),
    ),
    path: v.optional(v.string()),
    index: v.optional(v.boolean()),
    caseSensitive: v.optional(v.boolean()),
    file: v.string(),
    children: v.optional(v.array(v.lazy(() => routeConfigEntrySchema))),
  }),
);

export function validateRouteConfig({ routeConfigFile, routeConfig }): Result {
  if (!Array.isArray(routeConfig)) {
    return {
      valid: false,
      message: `Route config in "${routeConfigFile}" must be an array.`,
    };
  }

  let { issues } = v.safeParse(resolvedRouteConfigSchema, routeConfig);

  if (issues?.length) {
    let { root, nested } = v.flatten(issues);
    return {
      valid: false,
      message: [
        `Route config in "${routeConfigFile}" is invalid.`,
        nested
          ? Object.entries(nested).map(
              ([path, message]) => `Path: routes.${path}\n${message}`,
            )
          : [],
      ].flat().join("\n\n"),
    };
  }

  return { valid: true, routeConfig };
}

验证错误示例:

Auto
Route config in "routes.ts" is invalid.

Path: routes.0.children.0
Invalid type: Expected object but received a promise. Did you forget to await?

树形结构到扁平映射的转换#

configRoutesToRouteManifest 将嵌套的 RouteConfigEntry[] 转换为扁平的 RouteManifest

TypeScript
// packages/react-router-dev/config/routes.ts

export function configRoutesToRouteManifest(
  appDirectory: string,
  routes: RouteConfigEntry[],
): RouteManifest {
  let routeManifest: RouteManifest = {};

  function walk(route: RouteConfigEntry, parentId?: string) {
    // 生成路由 ID(默认使用文件路径)
    let id = route.id || createRouteId(route.file);

    let manifestItem: RouteManifestEntry = {
      id,
      parentId,
      file: Path.isAbsolute(route.file)
        ? Path.relative(appDirectory, route.file)
        : route.file,
      path: route.path,
      index: route.index,
      caseSensitive: route.caseSensitive,
    };

    // 检测重复 ID
    if (routeManifest.hasOwnProperty(id)) {
      throw new Error(`Unable to define routes with duplicate route id: "${id}"`);
    }

    routeManifest[id] = manifestItem;

    // 递归处理子路由
    if (route.children) {
      for (let child of route.children) {
        walk(child, id);
      }
    }
  }

  for (let route of routes) {
    walk(route);
  }

  return routeManifest;
}

function createRouteId(file: string) {
  return Path.normalize(stripFileExtension(file));
}

转换示例:

TypeScript
// 输入:嵌套结构
const routeConfig = [
  {
    id: "root",
    path: "",
    file: "root.tsx",
    children: [
      { path: "about", file: "routes/about.tsx" },
      {
        path: "posts",
        file: "routes/posts.tsx",
        children: [
          { path: ":id", file: "routes/posts.$id.tsx" }
        ]
      },
    ]
  }
];

// 输出:扁平映射
const routeManifest = {
  "root": { id: "root", file: "root.tsx", path: "" },
  "routes/about": { id: "routes/about", parentId: "root", file: "routes/about.tsx", path: "about" },
  "routes/posts": { id: "routes/posts", parentId: "root", file: "routes/posts.tsx", path: "posts" },
  "routes/posts.$id": { id: "routes/posts.$id", parentId: "routes/posts", file: "routes/posts.$id.tsx", path: ":id" },
};

路由辅助函数#

React Router 提供了一组辅助函数简化路由定义:

TypeScript
// packages/react-router-dev/config/routes.ts

function route(
  path: string | null | undefined,
  file: string,
  optionsOrChildren?: CreateRouteOptions | RouteConfigEntry[],
  children?: RouteConfigEntry[],
): RouteConfigEntry {
  let options: CreateRouteOptions = {};

  if (Array.isArray(optionsOrChildren) || !optionsOrChildren) {
    children = optionsOrChildren;
  } else {
    options = optionsOrChildren;
  }

  return {
    file,
    children,
    path: path ?? undefined,
    ...pick(options, ['id', 'index', 'caseSensitive']),
  };
}

function index(file: string, options?: CreateIndexOptions): RouteConfigEntry {
  return {
    file,
    index: true,
    ...pick(options, ['id']),
  };
}

function layout(file: string, ...args): RouteConfigEntry {
  // 类似 route,但没有 path
  return { file, children, ...pick(options, ['id']) };
}

function prefix(prefixPath: string, routes: RouteConfigEntry[]): RouteConfigEntry[] {
  return routes.map((route) => {
    if (route.index || typeof route.path === "string") {
      return {
        ...route,
        path: route.path ? joinRoutePaths(prefixPath, route.path) : prefixPath,
      };
    } else if (route.children) {
      return { ...route, children: prefix(prefixPath, route.children) };
    }
    return route;
  });
}

使用示例:

TypeScript
// app/routes.ts
import { route, index, layout, prefix } from "@react-router/dev/routes";

export default [
  layout("layouts/main.tsx", [
    index("routes/home.tsx"),
    route("about", "routes/about.tsx"),
    ...prefix("blog", [
      index("routes/blog/index.tsx"),
      route(":slug", "routes/blog/post.tsx"),
    ]),
  ]),
];

Vite 插件集成#

Vite 插件负责将路由配置集成到构建流程中。核心是 updatePluginContext 函数:

TypeScript
// packages/react-router-dev/vite/plugin.ts

let updatePluginContext = async (): Promise<void> => {
  let reactRouterConfigResult = await reactRouterConfigLoader.getConfig();

  if (!reactRouterConfigResult.ok) {
    logger.error(reactRouterConfigResult.error);
    return;
  }

  let reactRouterConfig = reactRouterConfigResult.value;

  ctx = {
    environmentBuildContext,
    reactRouterConfig,  // 包含 routes manifest
    rootDirectory,
    entryClientFilePath,
    entryServerFilePath,
    publicPath,
    viteManifestEnabled,
    buildManifest,
  };
};

插件生成虚拟的 server build 模块:

TypeScript
let getServerEntry = async ({ routeIds }) => {
  return `
    import * as entryServer from ${JSON.stringify(entryServerFilePath)};
    ${Object.keys(routes).map((key, index) => {
      let route = routes[key];
      return \`import * as route\${index} from "\${routeFilePath}";\`;
    }).join("\n")}

    export const routes = {
      ${Object.keys(routes).map((key, index) => {
        let route = routes[key];
        return \`"\${key}": {
          id: "\${route.id}",
          parentId: \${JSON.stringify(route.parentId)},
          path: \${JSON.stringify(route.path)},
          index: \${JSON.stringify(route.index)},
          module: route\${index}
        }\`;
      }).join(",\n")}
    };
  `;
};

Watch 模式#

开发时,配置加载器会监听文件变化并自动重新加载:

TypeScript
// packages/react-router-dev/config/config.ts

fsWatcher = chokidar.watch([root, appDirectory], {
  ignoreInitial: true,
  ignored: (path) => {
    let dirname = Path.dirname(path);
    return (
      !dirname.startsWith(appDirectory) &&
      path !== root &&
      dirname !== root
    );
  },
});

fsWatcher.on("all", async (event, rawFilepath) => {
  let filepath = Path.normalize(rawFilepath);

  // 检测文件是否在模块图中(routes.ts 或 config 的依赖)
  let moduleGraphChanged =
    configFileAddedOrRemoved ||
    Boolean(viteNodeContext.devServer?.moduleGraph.getModuleById(filepath));

  if (!moduleGraphChanged && !appFileAddedOrRemoved) return;

  // 清除 vite-node 缓存并重新解析配置
  viteNodeContext.devServer?.moduleGraph.invalidateAll();
  viteNodeContext.runner?.moduleCache.clear();

  let result = await getConfig();

  // 通知变更处理器
  for (let handler of changeHandlers) {
    handler({
      result,
      configCodeChanged,
      routeConfigCodeChanged,
      configChanged,
      routeConfigChanged,
      path: filepath,
      event,
    });
  }
});

Vite 插件会响应这些变化:

TypeScript
// packages/react-router-dev/vite/plugin.ts

reactRouterConfigLoader.onChange(async ({ result, configChanged, routeConfigChanged }) => {
  if (!result.ok) {
    invalidateVirtualModules(viteDevServer);
    logger.error(result.error);
    return;
  }

  let message =
    configChanged ? "Config changed." :
    routeConfigChanged ? "Route config changed." :
    "Config saved.";

  logger.info(colors.green(message));

  await updatePluginContext();

  if (configChanged || routeConfigChanged) {
    invalidateVirtualModules(viteDevServer);
  }
});

文件系统路由#

@react-router/fs-routes 包提供了文件系统路由约定:

TypeScript
// packages/react-router-fs-routes/index.ts

export async function flatRoutes(options = {}): Promise<RouteConfigEntry[]> {
  let { ignoredRouteFiles = [], rootDirectory = "routes" } = options;
  let appDirectory = getAppDirectory();
  let routesDir = path.resolve(appDirectory, rootDirectory);

  let routes = fs.existsSync(routesDir)
    ? flatRoutesImpl(appDirectory, ignoredRouteFiles, prefix)
    : {};

  return routeManifestToRouteConfig(routes);
}

文件名解析使用状态机处理特殊字符:

语法 含义 示例
$param 动态参数 $id.tsx:id
($param) 可选参数 ($lang).tsx:lang?
[escape] 字面转义 [sitemap.xml].tsxsitemap.xml
_prefix 无路径布局 _auth.tsx → 无 path
_index Index 路由 _index.tsx → index: true

类型生成#

React Router 会根据路由配置生成 TypeScript 类型:

TypeScript
// packages/react-router-dev/typegen/generate.ts

export function generateRoutes(ctx: Context): Array<VirtualFile> {
  // 生成 +routes.ts,包含 Pages、RouteFiles、RouteModules 类型
  const routesTs: VirtualFile = {
    filename: Path.join(typesDirectory(ctx), "+routes.ts"),
    content: `
      import "react-router"

      declare module "react-router" {
        interface Register {
          pages: Pages
          routeFiles: RouteFiles
          routeModules: RouteModules
        }
      }
    ` + pagesType(allPages) + routeFilesType(...) + routeModulesType(ctx),
  };

  // 为每个路由生成 +types/*.ts
  const allAnnotations = Array.from(fileToRoutes.entries())
    .filter(([file]) => isInAppDirectory(ctx, file))
    .map(([file, routeIds]) => getRouteAnnotations({ ctx, file, routeIds, lineages }));

  return [routesTs, ...allAnnotations];
}

生成的类型文件位于 .react-router/types/ 目录,提供完整的路由参数类型推断。

完整数据流#

整个数据流可以分为以下阶段:

  1. 执行阶段:用户的 routes.ts 文件通过 ViteNodeRunner.executeFile() 被转译并执行。
  2. 验证阶段validateRouteConfig() 使用 valibot schema 验证返回的 RouteConfigEntry[]
  3. 转换阶段configRoutesToRouteManifest() 将嵌套的路由配置转换为扁平的 RouteManifest
  4. 输出阶段:生成 ResolvedReactRouterConfig,包含 routesappDirectorybuildDirectory 等信息。
  5. 消费阶段:Vite Plugin 使用配置生成虚拟模块,Type Generator 在 .react-router/types/ 目录生成类型文件。