一只猫的橘 / Unsplashエッセイ
React Router v7 源码解析:modulepreload 的生成机制#
React Router v7 框架模式(Framework Mode)在 SSR 渲染时会自动注入 <link rel="modulepreload"> 标签,确保当前路由所需的所有 JavaScript 模块被并行预加载,消除模块依赖链带来的瀑布效应。
这篇文章将深入源码,分析这套机制的完整实现。
从渲染结果说起#
访问一个 React Router v7 应用时,查看 HTML 源码会发现类似这样的结构:
<head>
<link rel="modulepreload" href="/assets/manifest-a1b2c3.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="">
</head>
这些 modulepreload 标签包含了当前页面需要的所有 JavaScript 模块——入口文件、匹配的路由模块、以及它们的共享依赖。浏览器会并行下载并预处理这些模块,用户交互时模块已经处于可执行状态。
这套机制涉及两个阶段:构建阶段生成模块依赖清单,SSR 阶段根据路由匹配注入对应的 modulepreload 标签。
构建阶段:生成 Manifest#
React Router v7 使用 Vite 作为构建工具。构建完成后,Vite 会生成一个 manifest 文件,记录每个入口文件及其依赖的 chunk。React Router 的 Vite 插件会解析这个 manifest,为每个路由收集完整的依赖链。
核心数据结构#
路由的模块信息存储在 EntryRoute 类型中:
remix-run/react-router · packages/react-router/lib/dom/ssr/routes.tsx#L34-L49
export interface EntryRoute extends Route {
hasAction: boolean;
hasLoader: boolean;
hasClientAction: boolean;
hasClientLoader: boolean;
hasClientMiddleware: boolean;
hasErrorBoundary: boolean;
imports?: string[]; // 该路由的所有依赖模块
css?: string[]; // CSS 文件
module: string; // 主模块路径
clientActionModule: string | undefined; // clientAction 单独的模块
clientLoaderModule: string | undefined; // clientLoader 单独的模块
clientMiddlewareModule: string | undefined;// clientMiddleware 单独的模块
hydrateFallbackModule: string | undefined; // HydrateFallback 单独的模块
parentId?: string;
}
imports 数组是关键——它包含了该路由模块的所有静态依赖,包括共享 chunk(如 React、jsx-runtime 等)。
运行时使用的完整清单结构是 AssetsManifest:
remix-run/react-router · packages/react-router/lib/dom/ssr/entry.ts#L53-L66
export interface AssetsManifest {
entry: {
imports: string[]; // entry 的依赖模块
module: string; // entry 模块路径
};
routes: RouteManifest<EntryRoute>;
url: string; // manifest 文件 URL
version: string; // 版本哈希
hmr?: { // HMR 信息(开发环境)
timestamp?: number;
runtime: string;
};
sri?: Record<string, string> | true; // SRI 哈希表
}
依赖收集算法#
Vite 插件通过递归遍历 Vite 生成的 manifest,收集每个入口的完整依赖链:
remix-run/react-router · packages/react-router-dev/vite/plugin.ts#L422-L447
function resolveDependantChunks(
viteManifest: Vite.Manifest,
entryChunks: Vite.ManifestChunk[],
): Vite.ManifestChunk[] {
let chunks = new Set<Vite.ManifestChunk>();
function walk(chunk: Vite.ManifestChunk) {
if (chunks.has(chunk)) {
return; // 避免循环依赖
}
chunks.add(chunk);
if (chunk.imports) {
for (let importKey of chunk.imports) {
walk(viteManifest[importKey]);
}
}
}
for (let entryChunk of entryChunks) {
walk(entryChunk);
}
return Array.from(chunks);
}
这个函数从入口 chunk 开始,深度遍历所有静态 import,收集完整的依赖图。
为路由生成资源清单#
每个路由的资源信息通过 getReactRouterManifestBuildAssets 函数生成:
remix-run/react-router · packages/react-router-dev/vite/plugin.ts#L346-L420(简化版)
const getReactRouterManifestBuildAssets = (
ctx: ReactRouterPluginContext,
viteManifest: Vite.Manifest,
entryFilePath: string,
route: RouteManifestEntry | null,
) => {
let entryChunk = resolveChunk(ctx, viteManifest, entryFilePath);
// 收集所有依赖 chunks
let chunks = resolveDependantChunks(viteManifest, [
isRootRoute ? getClientEntryChunk(ctx, viteManifest) : null,
entryChunk,
// clientAction, clientLoader 等模块
].filter(Boolean));
return {
module: `${ctx.publicPath}${entryChunk.file}`,
imports: dedupe(
chunks.flatMap((e) => e.imports ?? [])
).map((imported) => `${ctx.publicPath}${viteManifest[imported].file}`),
css: dedupe(chunks.flatMap((e) => e.css ?? [])),
};
};
最终生成的 manifest 分为两部分:
Browser Manifest:写入 build/client/assets/manifest-{hash}.js,供客户端 hydration 使用。文件名中的 hash 基于 entry 和 routes 内容的 SHA256 哈希值的前 8 位,确保内容变更时 URL 也变更。
build/client/assets/manifest-a1b2c3d4.js
window.__reactRouterManifest={
"version": "a1b2c3d4",
"url": "/assets/manifest-a1b2c3d4.js",
"entry": {
"module": "/assets/entry.client-d4e5f6.js",
"imports": ["/assets/jsx-runtime-p6q7r8.js", "/assets/chunk-vendor-m3n4o5.js"]
},
"routes": {
"root": {
"id": "root",
"path": "",
"module": "/assets/root-g7h8i9.js",
"imports": ["/assets/chunk-vendor-m3n4o5.js"],
"clientActionModule": undefined,
"clientLoaderModule": undefined,
"clientMiddlewareModule": undefined,
"hydrateFallbackModule": undefined,
"hasAction": false,
"hasLoader": true,
"hasClientAction": false,
"hasClientLoader": false,
"hasClientMiddleware": false,
"hasErrorBoundary": true
},
"routes/home": {
"id": "routes/home",
"parentId": "root",
"path": "home",
"module": "/assets/routes/home-j0k1l2.js",
"imports": ["/assets/chunk-shared-v2w3x4.js"],
"clientActionModule": undefined,
"clientLoaderModule": "/assets/home.clientLoader-x1y2z3.js",
"clientMiddlewareModule": undefined,
"hydrateFallbackModule": undefined,
"hasAction": true,
"hasLoader": true,
"hasClientAction": false,
"hasClientLoader": true,
"hasClientMiddleware": false,
"hasErrorBoundary": false
}
},
"sri": undefined
};
Server Manifest:通过 virtual module (virtual:react-router/server-manifest) 内联到 server bundle 中,不生成独立文件。SSR 渲染时直接从内存读取。Server manifest 与 browser manifest 结构相同,但在使用 server bundles 功能时,routes 字段只包含当前 bundle 负责的路由:
remix-run/react-router · packages/react-router-dev/vite/plugin.ts#L2068-L2094
case virtual.serverManifest.resolvedId: {
let routeIds = getServerBundleRouteIds(this, ctx);
let reactRouterManifest =
viteCommand === "build"
? (await generateReactRouterManifestsForBuild({ viteConfig, routeIds }))
.reactRouterServerManifest
: await getReactRouterManifestForDev();
return `export default ${jsesc(reactRouterManifest, { es6: true })};`;
}
构建后,这段代码会被内联到 server bundle 中。典型的 server manifest 内容如下:
build/server/index.js(内联部分)
// virtual:react-router/server-manifest
var serverManifest = {
version: "a1b2c3d4",
url: "/assets/manifest-a1b2c3d4.js",
entry: {
module: "/assets/entry.client-d4e5f6.js",
imports: [
"/assets/jsx-runtime-p6q7r8.js",
"/assets/chunk-vendor-m3n4o5.js"
]
},
routes: {
root: {
id: "root",
path: "",
module: "/assets/root-g7h8i9.js",
imports: ["/assets/chunk-vendor-m3n4o5.js"],
css: ["/assets/root-s1t2u3.css"],
hasAction: false,
hasLoader: true,
hasClientAction: false,
hasClientLoader: false,
hasClientMiddleware: false,
hasErrorBoundary: true
},
"routes/home": {
id: "routes/home",
parentId: "root",
path: "home",
index: false,
module: "/assets/routes/home-j0k1l2.js",
imports: ["/assets/chunk-shared-v2w3x4.js"],
hasAction: true,
hasLoader: true,
hasClientAction: false,
hasClientLoader: true,
hasClientMiddleware: false,
hasErrorBoundary: false,
clientLoaderModule: "/assets/home.clientLoader-x1y2z3.js"
}
},
sri: void 0
};
export { serverManifest as default };
Browser manifest 写入磁盘的逻辑:
remix-run/react-router · packages/react-router-dev/vite/plugin.ts#L1070-L1091
// 基于 entry 和 routes 内容计算 8 位哈希作为版本号
let fingerprintedValues = { entry, routes: browserRoutes };
let version = getHash(JSON.stringify(fingerprintedValues), 8);
// 文件路径: build/client/assets/manifest-{version}.js
let manifestPath = path.posix.join(
viteConfig.build.assetsDir, // 默认是 "assets"
`manifest-${version}.js`
);
let url = `${ctx.publicPath}${manifestPath}`;
let reactRouterBrowserManifest = {
...fingerprintedValues,
url,
version,
sri: undefined,
};
// 写入文件,内容是一个 IIFE 赋值给 window.__reactRouterManifest
await writeFileSafe(
path.join(getClientBuildDirectory(ctx.reactRouterConfig), manifestPath),
`window.__reactRouterManifest=${JSON.stringify(reactRouterBrowserManifest)};`
);
getClientBuildDirectory 返回 {buildDirectory}/client,默认是 build/client。writeFileSafe 会自动创建目录结构:
const writeFileSafe = async (file: string, contents: string): Promise<void> => {
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, contents);
};
SSR 阶段:注入 modulepreload#
SSR 渲染时,<Scripts> 组件负责生成 modulepreload 标签。
Scripts 组件#
remix-run/react-router · packages/react-router/lib/dom/ssr/components.tsx#L729-L942(简化版)
export function Scripts(scriptProps: ScriptsProps) {
let { manifest, routeDiscovery, ssr } = useFrameworkContext();
let { router } = useDataRouterContext();
let { matches: routerMatches } = useDataRouterStateContext();
let enableFogOfWar = isFogOfWarEnabled(routeDiscovery, ssr);
let matches = getActiveMatches(routerMatches, null, isSpaMode);
// 收集需要预加载的模块(包括 HydrateFallback)
let preloads = dedupe(
manifest.entry.imports.concat(
getModuleLinkHrefs(matches, manifest, { includeHydrateFallback: true })
)
);
let sri = typeof manifest.sri === "object" ? manifest.sri : {};
return (
<>
{/* SRI importmap(如果启用) */}
{typeof manifest.sri === "object" ? (
<script
type="importmap"
dangerouslySetInnerHTML={{
__html: JSON.stringify({ integrity: sri }),
}}
/>
) : null}
{/* Manifest modulepreload(Fog of War 模式下跳过) */}
{!enableFogOfWar ? (
<link
rel="modulepreload"
href={manifest.url}
crossOrigin={scriptProps.crossOrigin}
integrity={sri[manifest.url]}
/>
) : null}
{/* Entry module modulepreload */}
<link
rel="modulepreload"
href={manifest.entry.module}
crossOrigin={scriptProps.crossOrigin}
integrity={sri[manifest.entry.module]}
/>
{/* 所有匹配路由的依赖模块 */}
{preloads.map((path) => (
<link
key={path}
rel="modulepreload"
href={path}
crossOrigin={scriptProps.crossOrigin}
integrity={sri[path]}
/>
))}
{/* 实际的 script 标签(包含 context 和 route modules) */}
{/* ... */}
</>
);
}
收集路由模块依赖#
getModuleLinkHrefs 函数遍历当前匹配的路由,收集所有需要预加载的模块:
remix-run/react-router · packages/react-router/lib/dom/ssr/links.ts#L246-L273
export function getModuleLinkHrefs(
matches: AgnosticDataRouteMatch[],
manifest: AssetsManifest,
{ includeHydrateFallback }: { includeHydrateFallback?: boolean } = {},
): string[] {
return dedupeHrefs(
matches
.map((match) => {
let route = manifest.routes[match.route.id];
if (!route) return [];
let hrefs = [route.module];
// 包含分离的 clientAction/clientLoader 模块
if (route.clientActionModule) {
hrefs = hrefs.concat(route.clientActionModule);
}
if (route.clientLoaderModule) {
hrefs = hrefs.concat(route.clientLoaderModule);
}
// 可选:包含 HydrateFallback 模块
if (includeHydrateFallback && route.hydrateFallbackModule) {
hrefs = hrefs.concat(route.hydrateFallbackModule);
}
// 包含所有静态依赖
if (route.imports) {
hrefs = hrefs.concat(route.imports);
}
return hrefs;
})
.flat(1)
);
}
这个函数做了几件事:
- 遍历当前 URL 匹配到的所有路由(包括嵌套的 layout 路由)。
- 对每个路由,收集主模块、clientAction/clientLoader 模块、以及所有静态依赖。
- 可选地包含
HydrateFallback模块(用于 Streaming SSR 场景)。 - 去重后返回完整的模块列表。
客户端导航:预加载下一页#
对于客户端导航,React Router 提供了 <Link prefetch> 属性和 <PrefetchPageLinks> 组件来预加载目标页面的资源。
// 用户悬停时预加载
<Link to="/dashboard" prefetch="intent">Dashboard</Link>
// 进入视口时预加载
<Link to="/settings" prefetch="viewport">Settings</Link>
内部实现使用 <PrefetchPageLinks> 组件:
remix-run/react-router · packages/react-router/lib/dom/ssr/components.tsx#L325-L487(简化版)
function PrefetchPageLinksImpl({ page, matches: nextMatches, ...linkProps }) {
let location = useLocation();
let { manifest, routeModules } = useFrameworkContext();
let { matches } = useDataRouterStateContext();
// 计算需要预取数据的新路由
let newMatchesForData = getNewMatchesForLinks(
page, nextMatches, matches, manifest, location, "data"
);
// 计算需要预加载资源的新路由
let newMatchesForAssets = getNewMatchesForLinks(
page, nextMatches, matches, manifest, location, "assets"
);
// 计算 single-fetch 的数据 URL
let dataHrefs = React.useMemo(() => {
// ... single-fetch URL 构建逻辑
return [singleFetchUrl(page, basename, "data").href];
}, [/* deps */]);
// 获取模块预加载 URL
let moduleHrefs = getModuleLinkHrefs(newMatchesForAssets, manifest);
return (
<>
{/* 数据预取 */}
{dataHrefs.map((href) => (
<link key={href} rel="prefetch" as="fetch" href={href} {...linkProps} />
))}
{/* 模块预加载 */}
{moduleHrefs.map((href) => (
<link key={href} rel="modulepreload" href={href} {...linkProps} />
))}
</>
);
}
这确保了用户导航时,目标页面的模块和数据已经被预加载和预处理。
Fog of War:按需发现路由#
React Router v7 支持 "Fog of War" 模式——懒加载路由发现。启用后,初始 HTML 只包含当前匹配路由的 modulepreload,manifest 也被裁剪为只包含这些路由:
remix-run/react-router · packages/react-router/lib/dom/ssr/fog-of-war.ts#L31-L67
export function getPartialManifest(
{ sri, ...manifest }: AssetsManifest,
router: DataRouter,
) {
// 从当前匹配的路由开始
let routeIds = new Set(router.state.matches.map((m) => m.route.id));
// 遍历父路径,匹配可能存在的 pathless/index 子路由
let segments = router.state.location.pathname.split("/").filter(Boolean);
let paths: string[] = ["/"];
segments.pop(); // 最后一个 segment 已经匹配过了
while (segments.length > 0) {
paths.push(`/${segments.join("/")}`);
segments.pop();
}
paths.forEach((path) => {
let matches = matchRoutes(router.routes, path, router.basename);
if (matches) {
matches.forEach((m) => routeIds.add(m.route.id));
}
});
let initialRoutes = [...routeIds].reduce(
(acc, id) => Object.assign(acc, { [id]: manifest.routes[id] }),
{}
);
return {
...manifest,
routes: initialRoutes,
sri: sri ? true : undefined, // SRI 只保留布尔标记
};
}
当用户导航到新路由时,客户端通过 /__manifest 端点按需获取新路由的信息:
// 请求格式: /__manifest?paths=/dashboard,/settings&version=a1b2c3d4
let url = new URL(getManifestPath(manifestPath, basename), window.location.origin);
searchParams.set("paths", paths.sort().join(","));
searchParams.set("version", manifest.version);
这减少了初始 manifest 的体积,适合路由数量较多的应用。
预加载动态导入的模块#
React Router 的 modulepreload 机制基于构建时的静态分析——只有通过 import 语句静态导入的模块才会被收集到 manifest 的 imports 数组中。如果路由组件内部使用了 React.lazy() 动态导入其他组件,这些动态导入的 chunk 不会被自动预加载。
例如,以下代码中的 ./heavy-chart.tsx 不会出现在 modulepreload 列表中:
// routes/dashboard.tsx
import { lazy, Suspense } from "react";
const HeavyChart = lazy(() => import("./heavy-chart.tsx"));
export default function Dashboard() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
);
}
这意味着用户访问 /dashboard 时,heavy-chart.tsx 对应的 chunk 要等到组件渲染时才开始加载,形成额外的网络瀑布。
通过 links 函数介入预加载#
React Router 提供了 links 导出函数,允许路由模块声明额外需要预加载的资源。这个函数返回的 LinkDescriptor 数组会被 <Links> 组件渲染为 <link> 标签。
remix-run/react-router · packages/react-router/lib/dom/ssr/links.ts#L18-L37
export function getKeyedLinksForMatches(
matches: AgnosticDataRouteMatch[],
routeModules: RouteModules,
manifest: AssetsManifest,
): KeyedLinkDescriptor[] {
let descriptors = matches
.map((match): LinkDescriptor[][] => {
let module = routeModules[match.route.id];
let route = manifest.routes[match.route.id];
return [
route && route.css
? route.css.map((href) => ({ rel: "stylesheet", href }))
: [],
module?.links?.() || [], // 调用路由模块的 links 函数
];
})
.flat(2);
let preloads = getModuleLinkHrefs(matches, manifest);
return dedupeLinkDescriptors(descriptors, preloads);
}
要预加载动态导入的模块,可以在 links 函数中返回 rel: "modulepreload" 的描述符:
// routes/dashboard.tsx
import { lazy, Suspense } from "react";
import type { LinksFunction } from "react-router";
const HeavyChart = lazy(() => import("./heavy-chart.tsx"));
export const links: LinksFunction = () => [
{
rel: "modulepreload",
href: "/assets/heavy-chart-a1b2c3.js", // 构建后的 chunk 路径
},
];
export default function Dashboard() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
);
}
这样 <Links> 组件会渲染出对应的 modulepreload 标签,浏览器会并行预加载这个 chunk。
动态获取 chunk 路径#
上面的例子中,chunk 路径是硬编码的。实际项目中,构建后的文件名包含哈希值,每次构建都可能变化。
为什么不能用 ?url 后缀?
你可能想到使用 Vite 的 ?url 后缀来获取模块的 URL:
import chartUrl from "./heavy-chart.tsx?url";
但这种方式不适用于 JS/TS 模块的预加载。?url 后缀会将文件作为静态资源处理,直接返回文件的 URL 而不经过 JS 模块的打包流程。这意味着:
import('./heavy-chart.tsx')动态导入会生成一个经过 tree-shaking、代码分割的 chunk,如/assets/heavy-chart-a1b2c3.jsimport url from './heavy-chart.tsx?url'返回的是原始文件的 URL,如/assets/heavy-chart-d4e5f6.tsx(注意扩展名不同)
两者指向完全不同的文件。?url 适用于图片、字体等静态资源,但不能用于获取动态导入模块的 chunk 路径。
方案一:从 Vite manifest 读取 chunk 映射
构建完成后,Vite 会生成 .vite/manifest.json(需要在 vite.config.ts 中设置 build.manifest: true),其中包含每个入口文件到输出 chunk 的映射。动态导入的模块也会作为入口记录在内。
manifest 的结构如下:
{
"app/routes/dashboard.tsx": {
"file": "assets/dashboard-a1b2c3.js",
"imports": ["assets/chunk-vendor-x7y8z9.js"],
"isEntry": true
},
"app/routes/heavy-chart.tsx": {
"file": "assets/heavy-chart-d4e5f6.js",
"imports": ["assets/chunk-shared-m3n4o5.js"],
"isDynamicEntry": true
}
}
可以直接在服务端读取这个文件,然后在 links 函数中使用。但问题在于 links 函数运行在模块作用域,无法直接进行文件 I/O。一个可行的方案是编写 Vite 插件,将 manifest 信息注入为虚拟模块:
// vite-plugin-manifest-virtual.ts
import type { Plugin } from "vite";
import { readFileSync } from "fs";
import { resolve } from "path";
export function manifestVirtualPlugin(): Plugin {
return {
name: "manifest-virtual",
resolveId(id) {
if (id === "virtual:vite-manifest") {
return "\0virtual:vite-manifest";
}
},
load(id) {
if (id === "\0virtual:vite-manifest") {
// 开发环境返回空对象,生产环境读取实际 manifest
if (process.env.NODE_ENV !== "production") {
return "export default {}";
}
const manifestPath = resolve("dist/client/.vite/manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
return `export default ${JSON.stringify(manifest)}`;
}
},
};
}
然后在路由模块中使用:
// routes/dashboard.tsx
import manifest from "virtual:vite-manifest";
import type { LinksFunction } from "react-router";
export const links: LinksFunction = () => {
const chunk = manifest["app/routes/heavy-chart.tsx"];
if (!chunk) return [];
return [{ rel: "modulepreload", href: `/${chunk.file}` }];
};
方案二:使用 @loadable/component
@loadable/component 是一个专门为 SSR 设计的代码分割库。它通过 Babel 插件和 Webpack/Vite 插件在构建时收集动态导入的 chunk 信息,并提供 ChunkExtractor API 在 SSR 时提取当前渲染所需的所有 chunk。
这种方案需要更多的配置工作,但提供了更完整的 SSR 代码分割支持,包括自动收集嵌套的动态导入。具体用法参考其官方文档。
links 函数的其他用途#
links 函数不仅可以预加载模块,还支持其他类型的资源预加载:
export const links: LinksFunction = () => [
// 预加载字体
{
rel: "preload",
href: "/fonts/inter-var.woff2",
as: "font",
type: "font/woff2",
crossOrigin: "anonymous",
},
// 预连接到 API 服务器
{
rel: "preconnect",
href: "https://api.example.com",
},
// 预加载关键图片
{
rel: "preload",
href: "/images/hero.webp",
as: "image",
},
// DNS 预解析
{
rel: "dns-prefetch",
href: "https://cdn.example.com",
},
];
SRI 支持#
React Router v7 支持 Subresource Integrity (SRI),通过 unstable_subResourceIntegrity 配置启用:
// react-router.config.ts
export default {
future: {
unstable_subResourceIntegrity: true
}
};
启用后,modulepreload 标签会包含 integrity 属性:
<link
rel="modulepreload"
href="/assets/entry.client-d4e5f6.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/..."
crossorigin=""
>
构建阶段会为每个 chunk 计算 SHA-384 哈希,存储在 manifest 的 sri 字段中。
完整流程#
整个 modulepreload 机制可以分为以下步骤:
构建阶段:
- Vite 构建生成 chunk 和原生 manifest。
- React Router 插件解析 Vite manifest,递归收集每个路由的依赖。
- 生成 React Router manifest,包含路由 → 模块 → 依赖的完整映射。
- (可选)计算 SRI 哈希。
SSR 阶段:
- 请求到达,路由匹配确定需要渲染的路由。
<Scripts>组件从 manifest 读取入口模块和匹配路由的依赖。- 渲染 modulepreload 标签到 HTML head。
- 浏览器并行下载和预处理所有模块。
客户端导航:
<Link prefetch>或<PrefetchPageLinks>检测导航意图。- 计算目标页面新增的路由。
- 动态注入 modulepreload 标签预加载模块。
小结#
React Router v7 的 modulepreload 机制展示了现代 SSR 框架如何优化模块加载:
- 构建时静态分析:通过解析 Vite manifest,在构建阶段确定每个路由的完整依赖图,避免运行时计算。
- 按路由精确预加载:只预加载当前页面需要的模块,不浪费带宽。
- 客户端导航优化:通过 prefetch 机制提前加载下一页的模块。
- 渐进式路由发现:Fog of War 模式支持大型应用的按需加载。
如果你想在自己的 SSR 框架中实现类似机制,关键点是:
- 构建阶段生成模块依赖图。
- SSR 渲染时根据路由匹配注入 modulepreload。
- 客户端导航时动态预加载目标页面的模块。