React Router 框架配置体系解析:`react-router.config.ts` 的设计决策与源码实现
本文围绕 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.ts 的 reactRouter({ ... }) 调用参数里。这种方案在功能简单时尚可运作,但随着 react-router CLI 能力增强,ADR 中记录了四个关键问题:
- 与 Vite 强耦合。
react-router routes和react-router typegen这两条 CLI 命令需要读取框架配置,但它们与 Vite 本身毫无关系。此前的变通做法是"用 Vite 去解析vite.config.ts,再从解析后的 Vite 配置对象里抽出 React Router 配置",随着 CLI 功能增加,这条路径越来越难以维护。 - 配置监听能力受限。
react-router typegen --watch不仅需要解析配置,还需要监听配置变化。把这件事绑定在 Vite 配置上,使监听实现变得不必要地复杂。 - 配置更新"一刀切"。修改 Vite 插件选项会被当作 Vite 配置的整体变更,触发开发服务器全量 reload,失去了对配置更新做更优雅处理的空间。
- 文档编写困难。要完整展示配置项,就得贴一个包含大量无关噪音的 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 config(config.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.ts 和 app/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)。仓库中的实现印证了这一点:
- packages/react-router-dev/config.ts 对外重新导出
ReactRouterConfig(别名Config)、BuildManifest、Preset、ServerBundlesFunction四个类型; - packages/react-router-dev/package.json 的
exports字段将./config映射到./dist/config.js,用户只需import type { Config } from "@react-router/dev/config"即可使用。
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_enableNodeReadableStream、unstable_optimizeDeps(config.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 + /__manifest,ssr: false 时为 initial |
控制"懒路由发现"行为 |
buildEnd |
(args) => void | Promise |
— | 整个 React Router 构建完成后的钩子,可拿到 buildManifest、reactRouterConfig、viteConfig |
serverBundles |
(args) => string | Promise<string> |
— | 将路由分配到不同服务端 bundle 的函数;注意解析逻辑中当 ssr: false 时 serverBundles 会被置为 undefined(config.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.subResourceIntegrity(config.ts#L696-L744)。 - 根路由模块必须存在,否则报错提示在 app 目录下找不到 root 路由模块(config.ts#L618-L627)。
解析完成后,ResolvedReactRouterConfig(config.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-L881、config.ts#L1231-L1261); - 当
react-router.config文件本身被增删时,updateReactRouterConfigFile()会重新发现文件路径,随后失效 Vite module graph 并清空 runner 缓存,重新getConfig()(config.ts#L888-L922); - 每次变化都会计算出四个布尔量,通过
onChange回调交给调用方决策如何处理:configCodeChanged:配置文件自身或其依赖图发生了代码变化(借助 isEntryFileDependency 沿 Vite 模块图递归回溯 importers 判断);routeConfigCodeChanged:routes.ts或其依赖发生变化;configChanged/routeConfigChanged:对比新旧ResolvedReactRouterConfig(分别排除/聚焦routes字段)判断语义上是否真的变了。
这意味着工具侧可以区分"代码变了但值没变"与"值确实变了",从而选择只重新生成类型、只做局部刷新,而不是重启开发服务器。react-router typegen --watch 正是这一机制的消费者:commands.ts 中的 typegen 在 --watch 时调用 Typegen.watch,后者底层同样基于 createConfigLoader 的监听能力。
CLI 如何"脱离 Vite"读取配置
ADR 背景中提到的第一条痛点(CLI 依赖 Vite 解析配置)在源码中已彻底消除:commands.ts 中 routes 命令(L25-L46)、generateEntry(L136-L139)以及 typegen 均直接调用 loadConfig。loadConfig 内部创建一个 watch: false 的一次性 ConfigLoader,加载完即 close(),全程不启动 Vite dev server,也不解析 vite.config.ts 中的插件选项——配置解析走的是独立的 Vite Runner 上下文(ViteRunner.createContext,config.ts#L816-L824)。命令还支持 --mode 与 REACT_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 返回的配置会被
omit掉presets键(excludedConfigPresetKeys,config.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 中的 ssr、prerender、routeDiscovery 等字段时,开发体验会显著轻于修改 Vite 配置;对二次开发者而言,packages/react-router-dev/config/config.ts 中类型定义、默认值、校验规则三者在同一文件内集中呈现,是理解整个框架配置体系的最佳入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00