# React Router v7 源码解析：modulepreload 的生成机制

- **HTML:** <https://blog.asuk.app/zh/p/react-router-v7-modulepreload>
- **Language:** `zh`
- **Author:** Asuka
- **Published:** `2026-01-08T00:00:00.000Z`
- **Updated:** `2026-10-13T10:41:48.000Z`
- **Tags:** React Router, Web Performance, Source Code Analysis

> 深入 React Router v7 框架模式源码，分析构建阶段如何基于 Vite manifest 收集模块依赖，以及 SSR 阶段如何通过 Scripts 组件注入 modulepreload 标签。

## Content

React Router v7 Framework Mode 在 SSR 渲染时，会通过 `<Scripts />` 组件自动注入 `<link rel="modulepreload">` 标签，让浏览器提前下载当前页面 hydration 所需的 JavaScript 模块。

这件事看起来只是 HTML 里多了几行 `<link>`，但背后其实串起了完整的构建和运行时链路：

```text
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 可能会包含类似这样的标签：

```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="" />
```

这些链接通常覆盖三类资源：

1. React Router browser manifest。
2. 客户端入口 `entry.client`。
3. 当前匹配路由的 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 大致长这样：

```ts
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 还会包含客户端入口：

```ts
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 生成资源信息。

核心思路是：

1. 找到 entry client chunk。
2. 找到每个 route module 对应的 entry chunk。
3. 找到 `clientLoader`、`clientAction`、`clientMiddleware`、`HydrateFallback` 等拆分 chunk。
4. 递归遍历这些 chunk 的 `imports`。
5. 去重后写入 React Router manifest。

源码里对应的关键函数是 `getReactRouterManifestBuildAssets` 和 `resolveDependantChunks`。

简化版逻辑如下：

```ts
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 里通常会看到：

```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 里看到：

```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 是发给浏览器用的。生产构建时，它通常会输出到类似：

```text
build/client/assets/manifest-{version}.js
```

内容大致是：

```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 中拿到：

- `manifest`
- `routeDiscovery`
- `ssr`
- `isSpaMode`
- `serverHandoffString`
- 当前 router matches
- SRI 信息

然后计算当前页面需要 preload 的模块：

```ts
let preloads = dedupe(
  manifest.entry.imports.concat(
    getModuleLinkHrefs(matches, manifest, {
      includeHydrateFallback: true,
    })
  )
);
```

这里有两部分：

1. `manifest.entry.imports`：客户端入口的共享依赖。
2. `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 输出：

```html
<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 机制可以概括为：

1. Vite 先生成底层构建 manifest。
2. React Router Vite 插件把 Vite chunk graph 转换成带路由语义的 manifest。
3. 每个 route entry 记录自己的 `module`、`imports`、`css` 和 client-side 拆分模块。
4. SSR 时 `<Scripts />` 根据当前 matches 调用 `getModuleLinkHrefs`。
5. 最终 HTML 输出 manifest、entry、route module、shared imports 的 `modulepreload`。
6. `<Links />` 负责 CSS，`<PrefetchPageLinks />` 负责未来导航预取。
7. route discovery、SRI、RSC、server bundles、CSS code split 等配置会影响最终输出。

所以，当你在源码里追一个 `<link rel="modulepreload">` 从哪里来时，不要只看 `<Scripts />`。完整链路应该从 Vite manifest、React Router manifest 生成、route matches、`getModuleLinkHrefs` 一直追到最终 SSR 输出。


## 参考

- [React Router source: SSR components.tsx](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/dom/ssr/components.tsx)
- [React Router source: SSR links.ts](https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/dom/ssr/links.ts)
- [React Router source: Vite plugin.ts](https://github.com/remix-run/react-router/blob/main/packages/react-router-dev/vite/plugin.ts)
- [MDN: rel="modulepreload"](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel/modulepreload)
- [Vite: Backend Integration / Manifest](https://vite.dev/guide/backend-integration.html)
