React Router v7 源码解析:modulepreload 的生成机制一只猫的橘 / Unsplash

エッセイ

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

Asuka 著

React Router v7 框架模式(Framework Mode)在 SSR 渲染时会自动注入 <link rel="modulepreload"> 标签,确保当前路由所需的所有 JavaScript 模块被并行预加载,消除模块依赖链带来的瀑布效应。

这篇文章将深入源码,分析这套机制的完整实现。

从渲染结果说起#

访问一个 React Router v7 应用时,查看 HTML 源码会发现类似这样的结构:

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

TypeScript
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

TypeScript
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

TypeScript
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(简化版)

TypeScript
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

JavaScript
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

TypeScript
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(内联部分)

JavaScript
// 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

TypeScript
// 基于 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/clientwriteFileSafe 会自动创建目录结构:

TypeScript
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(简化版)

TSX
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

TypeScript
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)
  );
}

这个函数做了几件事:

  1. 遍历当前 URL 匹配到的所有路由(包括嵌套的 layout 路由)。
  2. 对每个路由,收集主模块、clientAction/clientLoader 模块、以及所有静态依赖。
  3. 可选地包含 HydrateFallback 模块(用于 Streaming SSR 场景)。
  4. 去重后返回完整的模块列表。

客户端导航:预加载下一页#

对于客户端导航,React Router 提供了 <Link prefetch> 属性和 <PrefetchPageLinks> 组件来预加载目标页面的资源。

TSX
// 用户悬停时预加载
<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(简化版)

TSX
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

TypeScript
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 端点按需获取新路由的信息:

TypeScript
// 请求格式: /__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 列表中:

TSX
// 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 要等到组件渲染时才开始加载,形成额外的网络瀑布。

React Router 提供了 links 导出函数,允许路由模块声明额外需要预加载的资源。这个函数返回的 LinkDescriptor 数组会被 <Links> 组件渲染为 <link> 标签。

remix-run/react-router · packages/react-router/lib/dom/ssr/links.ts#L18-L37

TypeScript
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" 的描述符:

TSX
// 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:

TSX
import chartUrl from "./heavy-chart.tsx?url";

但这种方式不适用于 JS/TS 模块的预加载?url 后缀会将文件作为静态资源处理,直接返回文件的 URL 而不经过 JS 模块的打包流程。这意味着:

  • import('./heavy-chart.tsx') 动态导入会生成一个经过 tree-shaking、代码分割的 chunk,如 /assets/heavy-chart-a1b2c3.js
  • import 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 的结构如下:

JSON
{
  "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 信息注入为虚拟模块:

TypeScript
// 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)}`;
      }
    },
  };
}

然后在路由模块中使用:

TSX
// 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 函数不仅可以预加载模块,还支持其他类型的资源预加载:

TSX
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 配置启用:

TypeScript
// react-router.config.ts
export default {
  future: {
    unstable_subResourceIntegrity: true
  }
};

启用后,modulepreload 标签会包含 integrity 属性:

HTML
<link
  rel="modulepreload"
  href="/assets/entry.client-d4e5f6.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/..."
  crossorigin=""
>

构建阶段会为每个 chunk 计算 SHA-384 哈希,存储在 manifest 的 sri 字段中。

完整流程#

整个 modulepreload 机制可以分为以下步骤:

构建阶段

  1. Vite 构建生成 chunk 和原生 manifest。
  2. React Router 插件解析 Vite manifest,递归收集每个路由的依赖。
  3. 生成 React Router manifest,包含路由 → 模块 → 依赖的完整映射。
  4. (可选)计算 SRI 哈希。

SSR 阶段

  1. 请求到达,路由匹配确定需要渲染的路由。
  2. <Scripts> 组件从 manifest 读取入口模块和匹配路由的依赖。
  3. 渲染 modulepreload 标签到 HTML head。
  4. 浏览器并行下载和预处理所有模块。

客户端导航

  1. <Link prefetch><PrefetchPageLinks> 检测导航意图。
  2. 计算目标页面新增的路由。
  3. 动态注入 modulepreload 标签预加载模块。

小结#

React Router v7 的 modulepreload 机制展示了现代 SSR 框架如何优化模块加载:

  • 构建时静态分析:通过解析 Vite manifest,在构建阶段确定每个路由的完整依赖图,避免运行时计算。
  • 按路由精确预加载:只预加载当前页面需要的模块,不浪费带宽。
  • 客户端导航优化:通过 prefetch 机制提前加载下一页的模块。
  • 渐进式路由发现:Fog of War 模式支持大型应用的按需加载。

如果你想在自己的 SSR 框架中实现类似机制,关键点是:

  1. 构建阶段生成模块依赖图。
  2. SSR 渲染时根据路由匹配注入 modulepreload。
  3. 客户端导航时动态预加载目标页面的模块。

This browser prefers English.

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