Natalie Kinnear / Unsplashエッセイ
React Router v7 源码解析:routes.ts 的加载与解析机制#
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.ts 和 routes.ts |
@react-router/dev/vite/vite-node.ts |
创建 vite-node 上下文,用于执行 TypeScript 配置文件 |
@react-router/dev/vite/plugin.ts |
Vite 插件,将路由配置集成到构建流程 |
核心类型定义#
路由配置的核心类型定义在 config/routes.ts 中:
// 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 则是扁平化的映射:
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 语法,无需预编译。
// 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.ts 的 resolveConfig 函数中:
// 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 函数会查找支持的文件扩展名:
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 检查,提供清晰的错误信息:
// 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 };
}
验证错误示例:
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:
// 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));
}
转换示例:
// 输入:嵌套结构
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 提供了一组辅助函数简化路由定义:
// 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;
});
}
使用示例:
// 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 函数:
// 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 模块:
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 模式#
开发时,配置加载器会监听文件变化并自动重新加载:
// 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 插件会响应这些变化:
// 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 包提供了文件系统路由约定:
// 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].tsx → sitemap.xml |
_prefix |
无路径布局 | _auth.tsx → 无 path |
_index |
Index 路由 | _index.tsx → index: true |
类型生成#
React Router 会根据路由配置生成 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/ 目录,提供完整的路由参数类型推断。
完整数据流#
整个数据流可以分为以下阶段:
- 执行阶段:用户的
routes.ts文件通过ViteNodeRunner.executeFile()被转译并执行。 - 验证阶段:
validateRouteConfig()使用 valibot schema 验证返回的RouteConfigEntry[]。 - 转换阶段:
configRoutesToRouteManifest()将嵌套的路由配置转换为扁平的RouteManifest。 - 输出阶段:生成
ResolvedReactRouterConfig,包含routes、appDirectory、buildDirectory等信息。 - 消费阶段:Vite Plugin 使用配置生成虚拟模块,Type Generator 在
.react-router/types/目录生成类型文件。