エッセイ

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

Asuka 著本文: 中国語

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 有没有 clientLoaderclientActionclientMiddlewareHydrateFallback 等拆分出来的客户端模块?
  • 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. 找到 clientLoaderclientActionclientMiddlewareHydrateFallback 等拆分 chunk。
  4. 递归遍历这些 chunk 的 imports
  5. 去重后写入 React Router manifest。

源码里对应的关键函数是 getReactRouterManifestBuildAssetsresolveDependantChunks

简化版逻辑如下:

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 模式下,脚本注入和资源加载模型会不同,不能把这篇文章里的判断直接套过去。

<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 记录自己的 moduleimportscss 和 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 输出。

参考