首页
/ React Router 框架配置体系解析:`react-router.config.ts` 的设计决策与源码实现

React Router 框架配置体系解析:`react-router.config.ts` 的设计决策与源码实现

2026-09-06 17:47:53作者:齐冠琰

本文围绕 React Router 仓库中已接受的架构决策记录(ADR)decisions/0013-react-router-config.ts 展开:解释框架模式(Framework Mode)为什么要把配置从 vite.config.ts 中剥离出来、react-router.config.ts 的设计目标,并结合 配置解析实现CLI 命令Vite 插件 的源码,说明这一配置体系在解析、校验、监听和更新处理上的真实工作机理。读完后,你将理解该配置文件每个字段的默认值与校验规则,以及 react-router dev / react-router typegen --watch 等命令如何在不重启开发服务器的情况下响应配置变更。

背景:框架配置内嵌 vite.config.ts 的四个痛点

在早期版本(Remix 及 React Router 框架模式预览版)中,框架配置是以 options 对象的形式直接传给 Vite 插件的,即写在 vite.config.tsreactRouter({ ... }) 调用参数里。这种方案在功能简单时尚可运作,但随着 react-router CLI 能力增强,ADR 中记录了四个关键问题:

  1. 与 Vite 强耦合react-router routesreact-router typegen 这两条 CLI 命令需要读取框架配置,但它们与 Vite 本身毫无关系。此前的变通做法是"用 Vite 去解析 vite.config.ts,再从解析后的 Vite 配置对象里抽出 React Router 配置",随着 CLI 功能增加,这条路径越来越难以维护。
  2. 配置监听能力受限react-router typegen --watch 不仅需要解析配置,还需要监听配置变化。把这件事绑定在 Vite 配置上,使监听实现变得不必要地复杂。
  3. 配置更新"一刀切"。修改 Vite 插件选项会被当作 Vite 配置的整体变更,触发开发服务器全量 reload,失去了对配置更新做更优雅处理的空间。
  4. 文档编写困难。要完整展示配置项,就得贴一个包含大量无关噪音的 Vite 配置文件;只展示 reactRouter 插件调用却又被标注成 vite.config.ts,两种写法都不利于清晰地解释配置选项。

对应的四个设计目标是:把框架配置与 Vite 解耦支持细粒度配置监听(服务于 typegen --watch 等工具);避免配置变更引发无谓的 dev server reload通过分离两份配置改善文档体验

决策一:引入项目根目录下的专属配置文件 react-router.config.ts

ADR 的核心决策是在项目根目录引入独立的 react-router.config.ts(也支持 .js)。配置是可选的,但官方模板都会带一个空配置文件,用于提升配置项的可发现性和自文档化能力。仓库中的 playground 项目恰好展示了这一"可选但推荐"的模式——例如 playground/performance 就是一个"占位"的空配置:

// playground/performance/react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {} satisfies Config;

而在 playground/framework-spa 中,同一个文件只用来关闭 SSR:

import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;

源码中的定位与加载逻辑

配置文件的发现与加载全部发生在 @react-router/dev 包的 config 模块createConfigLoader 通过 findEntry(root, "react-router.config", { absolute: true }) 在根目录查找 react-router.config.{js,jsx,ts,tsx,mjs,mts}(见 findEntry),随后 resolveConfig 负责加载与校验,关键约束包括:

  • 文件一旦被发现后被删除,解析会返回错误 `${reactRouterConfigFile} no longer exists`
  • 模块必须提供 default export,且 default export 必须是对象,否则分别报 must provide a default export / must export a configconfig.ts#L456-L485);
  • 加载成功后,用户配置会先经过 cloneDeep + deepFreeze 处理,防止后续流程意外修改用户传入的配置(config.ts#L488)。

配置文件不存在时不报错——reactRouterUserConfig 初始化为 {},最终全部落回默认值。这与 ADR "Config file is optional but recommended" 的决策完全一致。

决策二:配置对象作为 default export 提供

ADR 要求"与其他 JS 构建工具的配置文件模式保持一致",即配置对象必须是 react-router.config.ts 的 default export。这一约定同样被推广到第二个配置文件——app/routes.ts:ADR 指出既然现在有 react-router.config.tsapp/routes.ts 两份配置文件,内部应当保持一致,统一使用 default export,而此时 routes.ts API 尚未稳定,正是做这一变更的时机。源码中 resolveConfig 加载 routes.ts 时同样读取其 .default 导出,并用 validateRouteConfig 校验(config.ts#L648-L661)。

决策三:类型从 @react-router/dev/config 命名空间导出

ADR 规定配置 API 应从 @react-router/dev/config 导出,遵循 @react-router/dev/* 这套"按文件作用域划分 dev 时 API"的既有模式(如 @react-router/dev/routes@react-router/dev/vite)。仓库中的实现印证了这一点:

Config 类型(源码中为 ReactRouterConfig,定义于 config.ts#L121-L270)的每个字段都带有 JSDoc 注释,官方 API 文档 docs/api/framework-conventions/react-router.config.ts.md 即基于该类型整理。结合两者,当前 Config 支持的选项及默认值如下(默认值取自 resolveConfig 中的 defaults 与逐项解析逻辑):

选项 类型 默认值 说明
appDirectory string "app" 相对根目录的 app 目录路径
buildDirectory string "build" 相对项目的构建输出目录
serverBuildFile string "index.js" 服务端构建产物的文件名,需以 .js 结尾并部署到服务器
serverModuleFormat "esm" | "cjs" "esm" 服务端构建产物的模块格式
ssr boolean true 设为 false 时进入 "SPA Mode":构建时请求 / 并保存为 index.html,按 SPA 部署
basename string "/" 应用 basename
splitRouteModules boolean | "enforce" true 是否自动将路由模块拆分为多个 chunk;"enforce" 要求所有路由必须可拆分
subResourceIntegrity boolean false 是否为资源 script 标签生成 SRI 哈希
allowedActionOrigins string[] false 允许向 UI 路由提交 action 的 origin 列表,支持 micromatch 通配符(* 匹配单段、** 匹配多段)
future Partial<FutureConfig> 各 flag 默认 false 未来特性开关,当前源码中的 FutureConfig 包含 unstable_enableNodeReadableStreamunstable_optimizeDepsconfig.ts#L96-L99
presets Array<Preset> 平台/工具集成的预设,见下文
prerender boolean | string[] | fn | { paths, concurrency } 构建时预渲染的 URL 列表,函数形式可通过 getStaticPaths() 动态生成;concurrency > 1 启用并发预渲染
routeDiscovery { mode: "lazy"; manifestPath? } | { mode: "initial" } ssr: true 时为 lazy + /__manifestssr: false 时为 initial 控制"懒路由发现"行为
buildEnd (args) => void | Promise 整个 React Router 构建完成后的钩子,可拿到 buildManifestreactRouterConfigviteConfig
serverBundles (args) => string | Promise<string> 将路由分配到不同服务端 bundle 的函数;注意解析逻辑中当 ssr: falseserverBundles 会被置为 undefinedconfig.ts#L544-L546

解析过程中的硬校验

resolveConfig 内嵌了若干校验规则,违反时返回明确的错误信息而不是静默回退,值得注意的有:

  • prerender 必须是布尔、字符串数组或返回它们的函数;对象形式只允许 { paths, concurrency },且 concurrency 必须是正整数(config.ts#L548-L583)。
  • ssr: false 时不允许 routeDiscovery.mode: "lazy"manifestPath 必须以 / 开头(config.ts#L585-L613)。
  • 已移除或已转正的 future flag 会报错并给出迁移指引,例如 future.v8_splitRouteModules 已迁移为顶层 config.splitRouteModules 字段、unstable_subResourceIntegrity 已转正为 config.subResourceIntegrityconfig.ts#L696-L744)。
  • 根路由模块必须存在,否则报错提示在 app 目录下找不到 root 路由模块(config.ts#L618-L627)。

解析完成后,ResolvedReactRouterConfigconfig.ts#L272-L354)同样被 deepFreeze 冻结,并额外包含解析结果 routes(RouteManifest)与 unstable_routeConfig(从 routes.ts 解析出的路由配置条目)。

决策四:Vite 插件不再接受配置选项

ADR 明确"Vite 插件将不再接受配置选项,所有框架选项都走专属配置文件"。当前源码正是如此:reactRouterVitePlugin 的函数签名就是零参数的 () => Vite.Plugin[],插件内部在 Vite 的 config 钩子中初始化 ctx,并自行调用 createConfigLoader 独立加载框架配置(plugin.ts#L1243)。也就是说,vite.config.ts 里现在只需要 plugins: [reactRouter()],不再有任何 reactRouter(options) 的入口,配置面与构建工具面彻底分开。

决策五:配置变更的细粒度监听与更新处理

这是 ADR 中最具工程价值的一条:配置变更不再触发全量 dev server reload,取而代之的是"细粒度配置监听"。实现位于 createConfigLoader

  • 监听由 chokidar 完成,只监视根目录和 app 目录,且通过 isIgnoredByWatcher 过滤掉无关路径(config.ts#L878-L881config.ts#L1231-L1261);
  • react-router.config 文件本身被增删时,updateReactRouterConfigFile() 会重新发现文件路径,随后失效 Vite module graph 并清空 runner 缓存,重新 getConfig()config.ts#L888-L922);
  • 每次变化都会计算出四个布尔量,通过 onChange 回调交给调用方决策如何处理:
    • configCodeChanged:配置文件自身或其依赖图发生了代码变化(借助 isEntryFileDependency 沿 Vite 模块图递归回溯 importers 判断);
    • routeConfigCodeChangedroutes.ts 或其依赖发生变化;
    • configChanged / routeConfigChanged:对比新旧 ResolvedReactRouterConfig(分别排除/聚焦 routes 字段)判断语义上是否真的变了。

这意味着工具侧可以区分"代码变了但值没变"与"值确实变了",从而选择只重新生成类型、只做局部刷新,而不是重启开发服务器。react-router typegen --watch 正是这一机制的消费者:commands.ts 中的 typegen--watch 时调用 Typegen.watch,后者底层同样基于 createConfigLoader 的监听能力。

CLI 如何"脱离 Vite"读取配置

ADR 背景中提到的第一条痛点(CLI 依赖 Vite 解析配置)在源码中已彻底消除:commands.tsroutes 命令(L25-L46)、generateEntryL136-L139)以及 typegen 均直接调用 loadConfigloadConfig 内部创建一个 watch: false 的一次性 ConfigLoader,加载完即 close(),全程不启动 Vite dev server,也不解析 vite.config.ts 中的插件选项——配置解析走的是独立的 Vite Runner 上下文(ViteRunner.createContextconfig.ts#L816-L824)。命令还支持 --modeREACT_ROUTER_ROOT 环境变量定位项目根(resolveRootDirectory)。

Presets:配置体系的平台扩展点

当前源码中的 ReactRouterConfig 类型还包含 ADR 时代之后新增的 presets 选项,它是"配置与外部平台解耦"理念的延伸:Preset 类型(config.ts#L43-L51)要求提供 name,以及可选的 reactRouterConfig(返回一份配置补丁)和 reactRouterConfigResolved(在完整配置解析完成后被回调)两个钩子。合并规则由 mergeReactRouterConfig 实现:

  • 多个 preset 依次与用户配置合并,用户配置优先级最高mergeReactRouterConfig(...presets, reactRouterUserConfig)config.ts#L523-L526);
  • 顶层字段直接覆盖,buildEnd 会被合成并行执行的组合钩子,future 做浅合并,presets 数组合并;
  • preset 返回的配置会被 omitpresets 键(excludedConfigPresetKeysconfig.ts#L35-L41),避免 preset 嵌套注入 preset 的递归问题。

仓库内已有平台 preset 的实践可参考 @react-router/cloudflare 相关模板(playground/vite-plugin-cloudflare)的集成方式。

小结

react-router.config.ts 这一决策的本质,是把"React Router 框架配置"从"Vite 插件的一个参数"提升为"一个独立的一等公民配置文件"。从 ADR 的四个目标对照当前仓库源码来看均已落地:配置解析(resolveConfig/createConfigLoader)与 CLI(loadConfig)完全绕开 vite.config.ts;监听基于 chokidar + Vite 模块图实现细粒度变更感知,替代全量 reload;Vite 插件签名已无任何选项参数;而官方文档也因此可以只展示一个干净、自包含的配置文件示例(如 官方配置文档 中的 satisfies Config 写法)。对使用者而言,这意味着:修改 react-router.config.ts 中的 ssrprerenderrouteDiscovery 等字段时,开发体验会显著轻于修改 Vite 配置;对二次开发者而言,packages/react-router-dev/config/config.ts 中类型定义、默认值、校验规则三者在同一文件内集中呈现,是理解整个框架配置体系的最佳入口。

登录后查看全文
热门项目推荐
相关项目推荐