エッセイ
React Router v7 源码解析:modulepreload 的生成机制#
React Router v7 Framework Mode 在 SSR 渲染时,会通过 <Scripts /> 组件自动注入 <link rel="modulepreload"> 标签,让浏览器提前下载当前页面 hydration 所需的 JavaScript 模块。
这件事看起来只是 HTML 里多了几行 <link>,但背后其实串起了完整的构建和运行时链路:
Vite build manifest
↓
React Router Vite plugin 收集 entry / route module / route chunk 依赖
↓
生成 React Router manifest
↓
SSR 时根据当前 route matches 计算需要 preload 的模块
↓
<Scripts /> 输出 modulepreload link
理解这条链路之后,很多 SSR 性能问题都会更容易定位:为什么某个 chunk 被提前加载?为什么某个 route module 没有 preload?为什么开发环境和构建产物不同?为什么 route discovery / Fog of War 会影响 manifest preload?
从渲染结果说起#
一个典型的 React Router Framework Mode SSR 页面里,HTML 可能会包含类似这样的标签:
<link rel="modulepreload" href="/assets/manifest-a1b2c3d4.js" crossorigin="" />
<link rel="modulepreload" href="/assets/entry.client-d4e5f6.js" crossorigin="" />
<link rel="modulepreload" href="/assets/root-g7h8i9.js" crossorigin="" />
<link rel="modulepreload" href="/assets/routes/home-j0k1l2.js" crossorigin="" />
<link rel="modulepreload" href="/assets/chunk-vendor-m3n4o5.js" crossorigin="" />
<link rel="modulepreload" href="/assets/jsx-runtime-p6q7r8.js" crossorigin="" />
这些链接通常覆盖三类资源:
- React Router browser manifest。
- 客户端入口
entry.client。 - 当前匹配路由的 route module 以及它们的静态 import 依赖。
modulepreload 和普通 preload 不完全一样。它是为 ES Module 设计的,浏览器不只是下载文件,还会按模块图提前解析、编译和准备依赖。这样后续真正执行 import 时,可以减少模块瀑布加载带来的延迟。
为什么 React Router 需要自己的 manifest#
Vite 本身会生成构建 manifest,里面记录入口文件、输出 chunk、CSS、imports 等信息。
但 React Router 不能直接把 Vite manifest 原样丢给浏览器,因为框架还需要回答几个更高层的问题:
- 当前 route id 对应哪个 route module?
- route module 有没有
clientLoader、clientAction、clientMiddleware、HydrateFallback等拆分出来的客户端模块? - root route 是否要额外包含 client entry 的资源?
- 当前 SSR matches 需要哪些 route module?
- route discovery 开启时,是否应该提前暴露完整 browser manifest?
- 如果启用了 SRI,link 和 import map 应该带什么 integrity?
因此 React Router 会在 Vite manifest 之上生成自己的 AssetsManifest / React Router manifest。
核心数据结构:EntryRoute 与 AssetsManifest#
运行时真正关心的是每个 route 的客户端资源信息。简化后,一个 route manifest entry 大致长这样:
type EntryRoute = {
id: string;
parentId?: string;
path?: string;
index?: boolean;
module: string;
imports?: string[];
css?: string[];
hasAction: boolean;
hasLoader: boolean;
hasClientAction: boolean;
hasClientLoader: boolean;
hasClientMiddleware: boolean;
hasErrorBoundary: boolean;
clientActionModule?: string;
clientLoaderModule?: string;
clientMiddlewareModule?: string;
hydrateFallbackModule?: string;
};
其中最关键的是:
module:这个 route 的主客户端模块。imports:主模块及相关拆分模块依赖的共享 chunk。css:这个 route 相关 CSS。clientLoaderModule/clientActionModule/clientMiddlewareModule/hydrateFallbackModule:框架为了减少不必要加载而拆出来的 route chunk。
完整 manifest 还会包含客户端入口:
type AssetsManifest = {
entry: {
module: string;
imports: string[];
};
routes: Record<string, EntryRoute>;
url: string;
version: string;
hmr?: {
timestamp?: number;
runtime: string;
};
sri?: Record<string, string> | true;
};
也就是说,React Router 的 manifest 是“路由语义 + 构建资源”的结合体。
构建阶段:从 Vite manifest 收集依赖#
React Router 的 Vite 插件会读取 Vite manifest,并为每个 route 生成资源信息。
核心思路是:
- 找到 entry client chunk。
- 找到每个 route module 对应的 entry chunk。
- 找到
clientLoader、clientAction、clientMiddleware、HydrateFallback等拆分 chunk。 - 递归遍历这些 chunk 的
imports。 - 去重后写入 React Router manifest。
源码里对应的关键函数是 getReactRouterManifestBuildAssets 和 resolveDependantChunks。
简化版逻辑如下:
function resolveDependantChunks(viteManifest, entryChunks) {
let chunks = new Set();
function walk(chunk) {
if (chunks.has(chunk)) return;
chunks.add(chunk);
for (let importKey of chunk.imports ?? []) {
walk(viteManifest[importKey]);
}
}
for (let entryChunk of entryChunks) {
walk(entryChunk);
}
return Array.from(chunks);
}
它做的是静态依赖图遍历:从 route module chunk 出发,顺着 Vite manifest 里的 imports 找到所有依赖 chunk。
需要注意:这里主要处理静态 import 依赖。动态 import 的 chunk 是否会提前加载,要看它是否被框架识别为 route 相关拆分模块,或者由其他 preload/prefetch 机制处理。
root route 为什么特殊#
源码里有一个很重要的分支:如果当前 route 是 root route,依赖收集时还会包含 client entry chunk。
原因是很多应用会在 entry.client.tsx 或相关入口里引入全局 reset 样式、全局初始化逻辑或共享依赖。root route 是整个应用壳层,因此它需要把这些入口相关资源纳入首屏资源集合。
这也解释了为什么 HTML 里通常会看到:
<link rel="modulepreload" href="/assets/entry.client-xxx.js" />
<link rel="modulepreload" href="/assets/root-xxx.js" />
它们分别来自客户端入口和 root route module。
imports 里为什么有 vendor / jsx-runtime#
Vite 构建后,React、jsx-runtime、UI 库、工具库等共享依赖通常会被拆成公共 chunk。
如果当前 route module 静态依赖了这些 chunk,React Router 会把它们加入 imports,最终 SSR 输出 modulepreload。
所以你在 HTML 里看到:
<link rel="modulepreload" href="/assets/chunk-vendor-xxx.js" />
<link rel="modulepreload" href="/assets/jsx-runtime-xxx.js" />
并不表示 React Router 手写了这些文件名,而是它从 Vite manifest 的 chunk graph 中递归收集出来的。
Browser manifest 与 Server manifest#
React Router 生成的 manifest 通常有两种使用形态。
Browser manifest#
Browser manifest 是发给浏览器用的。生产构建时,它通常会输出到类似:
build/client/assets/manifest-{version}.js
内容大致是:
window.__reactRouterManifest = {
version: "a1b2c3d4",
url: "/assets/manifest-a1b2c3d4.js",
entry: {
module: "/assets/entry.client-d4e5f6.js",
imports: ["/assets/jsx-runtime-p6q7r8.js"]
},
routes: {
root: {
id: "root",
module: "/assets/root-g7h8i9.js",
imports: ["/assets/chunk-vendor-m3n4o5.js"],
hasLoader: true,
hasAction: false
}
}
};
version 通常基于 entry 和 routes 内容 hash 生成,内容变了,manifest URL 也会变。这有利于浏览器缓存失效。
Server manifest#
Server manifest 给 SSR 过程使用。它通常通过 virtual module 注入到 server build 里,不需要在请求时再从磁盘读 browser manifest。
SSR 时,React Router 已经知道:
- 当前请求匹配了哪些 routes。
- 这些 routes 对应哪些 route module。
- 每个 route module 有哪些 imports/css。
- client entry 和 manifest URL 是什么。
于是 <Scripts /> 就可以生成正确的 modulepreload 标签。
如果启用了 server bundles,server manifest 还可能只包含当前 server bundle 负责的 route 子集。这个细节对大型应用拆分 server bundle 时比较重要。
SSR 阶段:<Scripts /> 做了什么#
<Scripts /> 是 React Router Framework Mode 里负责输出客户端脚本和部分 preload link 的组件。
它会从 framework context 中拿到:
manifestrouteDiscoveryssrisSpaModeserverHandoffString- 当前 router matches
- SRI 信息
然后计算当前页面需要 preload 的模块:
let preloads = dedupe(
manifest.entry.imports.concat(
getModuleLinkHrefs(matches, manifest, {
includeHydrateFallback: true,
})
)
);
这里有两部分:
manifest.entry.imports:客户端入口的共享依赖。getModuleLinkHrefs(matches, manifest, ...):当前 route matches 的 route module、route imports、HydrateFallback 等资源。
随后 <Scripts /> 会输出:
- browser manifest 的
modulepreload。 - entry client module 的
modulepreload。 - 当前 matches 对应模块的
modulepreload。 - hydration 所需的 inline data。
- 最终启动客户端应用的
<script type="module" ...>。
为什么 manifest 自己也会 modulepreload#
如果 route discovery 没有启用 Fog of War,React Router 会把 browser manifest 也作为 modulepreload 输出:
<link rel="modulepreload" href="/assets/manifest-a1b2c3d4.js" />
原因是客户端 hydration 和后续 navigation 需要知道 route 到资源的映射。提前加载 browser manifest 可以减少 hydration 之后再请求 manifest 的延迟。
但如果启用了 route discovery / Fog of War,框架可能不会在首屏 HTML 中暴露完整 manifest,而是按需发现 route 信息。此时 <Scripts /> 对 manifest preload 的行为会有所不同。
这也是调试时容易困惑的地方:同样是 React Router Framework Mode,是否看到 manifest preload,可能取决于 route discovery 配置。
SRI 与 importmap#
React Router manifest 里还可能包含 sri 字段。如果它是一个对象,<Scripts /> 会输出一个 type="importmap" 的脚本,把 URL 到 integrity hash 的映射交给浏览器。
同时,modulepreload link 也可以带上对应的 integrity。
这意味着在启用 SRI 的部署里,你不能只看 href 是否正确,还要检查:
- manifest 里的
sri是否包含对应 URL。 <link rel="modulepreload">是否带了正确的integrity。- import map 是否与最终资源 URL 一致。
否则资源可能被浏览器拒绝加载。
CSS 和 modulepreload 的关系#
这篇文章主要讲 JavaScript modulepreload,但 React Router manifest 也会收集 CSS。
CSS 的输出主要由 <Links /> 负责,而不是 <Scripts />。不过两者共享同一个 route manifest 资源图。
有一个特殊情况:如果 Vite 的 build.cssCodeSplit 关闭,Vite manifest 中可能出现统一的 style.css。React Router 会把这类全局 CSS 资源挂到 root route 上,保证 SSR 时能被 <Links /> 正确输出。
因此调试资源注入时,不要把 CSS link 和 JS modulepreload 混在一起看:
- CSS:主要看
<Links />。 - JS modulepreload:主要看
<Scripts />。 - 两者的数据来源:都是 React Router manifest。
RSC 场景下的变化#
当前源码中,<Scripts /> 在 RSC router context 下会变成 no-op,并给出提醒:使用 RSC 时可以移除 <Scripts />。
这意味着本文讨论的 <Scripts /> → modulepreload 链路,主要适用于传统 Framework Mode SSR / hydration 场景。RSC 模式下,脚本注入和资源加载模型会不同,不能把这篇文章里的判断直接套过去。
和 <PrefetchPageLinks /> 的区别#
<Scripts /> 注入的是当前页面 hydration 所需资源。
而 <PrefetchPageLinks /> 更偏向“未来导航”的预取。它会根据目标页面 page 计算新的 matches,并为即将访问的页面输出 data/module/css 预取链接。
简单说:
<Scripts />:当前页面必须加载。<PrefetchPageLinks />:用户可能马上要去的页面,提前预取。
两者都会用到 route manifest,但目的不同。
常见调试思路#
1. 页面没有出现预期的 route module preload#
先确认这个 route 是否真的在当前 SSR matches 里。只有当前匹配到的 route,才会被 <Scripts /> 作为 hydration 资源输出。
2. 共享 chunk 被 preload 了很多次#
React Router 会做 dedupe。最终 HTML 里如果仍有重复,通常要检查是否是其他插件、手写 link、CDN 注入或 HTML transform 又插了一遍。
3. manifest URL 没有出现在 HTML 中#
检查是否启用了 route discovery / Fog of War。开启后,完整 manifest 的暴露策略可能不同。
4. CSS 出现了,但 JS modulepreload 没出现#
分别看 <Links /> 和 <Scripts /> 是否都在 root.tsx 中正确渲染。很多问题不是 manifest 没生成,而是 root layout 漏了框架组件。
5. 本地 dev 和 production 不一致#
dev 模式下有 HMR runtime、虚拟模块、未 hash 的模块路径;production 构建则依赖最终 Vite manifest 和 hashed assets。调试时要区分两套路径。
小结#
React Router v7 的 modulepreload 机制可以概括为:
- Vite 先生成底层构建 manifest。
- React Router Vite 插件把 Vite chunk graph 转换成带路由语义的 manifest。
- 每个 route entry 记录自己的
module、imports、css和 client-side 拆分模块。 - SSR 时
<Scripts />根据当前 matches 调用getModuleLinkHrefs。 - 最终 HTML 输出 manifest、entry、route module、shared imports 的
modulepreload。 <Links />负责 CSS,<PrefetchPageLinks />负责未来导航预取。- route discovery、SRI、RSC、server bundles、CSS code split 等配置会影响最终输出。
所以,当你在源码里追一个 <link rel="modulepreload"> 从哪里来时,不要只看 <Scripts />。完整链路应该从 Vite manifest、React Router manifest 生成、route matches、getModuleLinkHrefs 一直追到最终 SSR 输出。
