文章

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 输出。

参考#

This browser prefers English.

You can switch the site to English and save that choice for future visits.