理解 preload 与 modulepreload:资源预加载完全指南

文章

理解 preload 与 modulepreload:资源预加载完全指南#

作者:Asuka

浏览器在解析 HTML 时会按顺序发现资源。但很多关键资源并不会第一时间出现在 HTML 里:字体藏在 CSS 的 @font-face 中,LCP 图片可能是 CSS 背景图,JavaScript 模块的深层依赖要等入口模块下载和解析后才会被发现。

<link rel="preload"><link rel="modulepreload"> 都是在解决“资源发现太晚”的问题。区别在于:

  • preload 是通用资源预加载机制,适用于字体、图片、CSS、fetch 等资源。
  • modulepreload 是 ES Module 专用机制,除了下载模块文件,还会按模块语义处理和准备模块。

理解这两个标签的差异,可以避免两类常见问题:一类是该预加载的关键资源太晚发现,另一类是过度预加载导致首屏带宽被浪费。

preload 是一个声明式资源获取指令,意思是:这个资源当前页面很快会用到,请浏览器尽早开始请求。

常见写法:

HTML
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/styles/critical.css" as="style">
<link rel="preload" href="/images/hero.webp" as="image" fetchpriority="high">
<link rel="preload" href="/api/bootstrap.json" as="fetch" crossorigin>

as 属性非常关键。它告诉浏览器资源类型,影响:

  • 请求优先级。
  • Accept header。
  • 内容安全策略匹配。
  • 缓存复用方式。
  • 是否需要 CORS。

常见 as 值包括:

as 适合资源 典型场景
font woff2 / woff 字体 首屏会用到的 Web Font
image 图片 LCP hero 图、CSS 背景图
style CSS 之后会被 stylesheet 使用的关键 CSS
script classic script 之后会被普通 script 使用的 JS
fetch JSON / API / WASM 等 fetch 请求 首屏 bootstrap 数据
worker Worker 脚本 很快会启动的 Worker

如果 as 写错,浏览器可能无法复用缓存,甚至发起重复请求。

preload 的工作原理#

浏览器看到 preload 后,一般会:

  1. 立即发起资源请求。
  2. 根据 astypecrossoriginfetchpriority 等信息决定优先级和请求模式。
  3. 把响应放入可复用的缓存位置。
  4. 等后续真正的消费者出现,例如 <img>@font-facefetch()<link rel="stylesheet">
  5. 如果请求模式匹配,就复用刚才的结果。

关键点是:preload 只负责提前获取,不负责实际使用。

例如:

HTML
<link rel="preload" href="/styles/critical.css" as="style">

这只会下载 CSS,不会把样式应用到页面。要应用样式,仍然需要:

HTML
<link rel="stylesheet" href="/styles/critical.css">

同理,预加载脚本不会执行脚本;预加载图片不会显示图片;预加载 JSON 不会自动把数据交给你的应用。

preload 的典型使用场景#

1. 预加载首屏字体#

字体通常在 CSS 中通过 @font-face 声明。浏览器要等 CSS 下载、解析,并确认页面上有文本实际需要这个字体后,才会请求字体文件。

如果这个字体影响首屏文本,可以显式 preload:

HTML
<link
  rel="preload"
  href="/fonts/inter-var.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>

字体 preload 通常需要 crossorigin。即使字体文件和页面同源,也要和实际字体请求的 CORS 模式保持一致,否则可能重复下载。

不过不要把所有字体都 preload。一般只预加载首屏真正会用到的 woff2,避免把粗体、斜体、多语言大字库全部塞进关键路径。

2. 预加载 LCP 图片#

如果 LCP 元素是一张很重要的 hero 图,尤其是 CSS 背景图,浏览器可能发现得很晚。

可以写:

HTML
<link
  rel="preload"
  href="/images/hero-banner.webp"
  as="image"
  fetchpriority="high"
>

如果是响应式图片,还可以配合 imagesrcsetimagesizes,让浏览器选择合适尺寸:

HTML
<link
  rel="preload"
  as="image"
  href="/hero-1280.webp"
  imagesrcset="/hero-640.webp 640w, /hero-1280.webp 1280w"
  imagesizes="100vw"
  fetchpriority="high"
>

现代页面里,LCP 图片往往也可以直接在 <img> 上使用 fetchpriority="high"。如果图片本身很早就出现在 HTML 中,单独 preload 未必有明显收益。

3. 预加载首屏 bootstrap 数据#

如果 SSR 页面 hydration 前必定要请求一份 JSON,可以考虑:

HTML
<link rel="preload" href="/api/bootstrap.json" as="fetch" crossorigin>

然后应用里正常 fetch:

TS
await fetch("/api/bootstrap.json", { credentials: "same-origin" });

这里要特别注意 crossorigincredentials 是否一致。请求模式不一致时,浏览器可能不会复用 preload 结果。

Fetch Priority 与 preload 的关系#

preload 会让资源更早进入请求队列,但优先级还会受到资源类型、浏览器策略和 fetchpriority 影响。

例如:

HTML
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<link rel="preload" href="/below-fold.webp" as="image" fetchpriority="low">

fetchpriority 是提示,不是强制命令。浏览器仍然会根据当前页面状态、连接情况、资源类型和调度策略做最终决定。

实践上:

  • LCP 图片可以考虑 fetchpriority="high"
  • 非关键图片不应该 preload;确实要 preload 时也应考虑 low
  • 不要用 preload + high 把一堆非关键资源塞进首屏竞争队列。

preload 的常见坑#

1. 预加载后没有及时使用#

Chrome 会对未及时使用的 preload 发出类似警告:

TEXT
The resource ... was preloaded using link preload but not used within a few seconds from the window's load event.

这通常说明资源并不属于当前页面关键路径,应该删除 preload 或改成 prefetch。

2. CORS / credentials 不匹配导致重复请求#

preload 的请求模式必须和实际使用时一致。

常见重复请求来源:

  • 字体 preload 忘记 crossorigin
  • as="fetch" 没有和后续 fetch() 的 credentials 模式匹配。
  • script / module 的 crossorigin 不一致。

3. 预加载过多,反而拖慢首屏#

preload 不是“越多越快”。它会让资源更早竞争带宽和连接。如果你把非关键资源都 preload,可能会挤占 CSS、字体、LCP 图片和主脚本。

一个实用原则是:只 preload 当前页面首屏必定会用、并且浏览器默认发现较晚的资源。

ES Module 的加载问题#

普通 <script> 通常是单个文件:浏览器下载、解析、执行。

ES Module 不一样。它天然形成依赖图:

TS
// main.js
import { helper } from "./utils.js";
import { render } from "./renderer.js";

浏览器加载模块入口时,必须:

  1. 下载入口模块。
  2. 解析入口模块,发现静态 import
  3. 下载依赖模块。
  4. 解析依赖模块,继续发现更深层依赖。
  5. 等整个模块图完成后,再按模块规则执行。

如果依赖层级很深,就会出现模块发现瀑布:每一层都要等上一层下载和解析后才能发现。

为什么不应该用普通 preload 解决模块图问题#

你当然可以写:

HTML
<link rel="preload" href="/assets/main.js" as="script" crossorigin>
<script type="module" src="/assets/main.js" crossorigin></script>

这在某些情况下可能让入口文件更早下载。但它不是 ES Module 的专用机制,存在几个问题:

  1. 它不会按照 module script 的语义准备模块记录。
  2. 它不会帮你处理模块图里的静态依赖。
  3. 它要求你非常小心地匹配 CORS / credentials 模式。
  4. 它很容易只优化入口文件,却没有解决深层依赖瀑布。

因此,对于 ES Module,语义上更正确的做法是 modulepreload

modulepreload 是为 module script 设计的预加载机制:

HTML
<link rel="modulepreload" href="/assets/main.js">
<link rel="modulepreload" href="/assets/vendor.js">
<script type="module" src="/assets/main.js"></script>

它和普通 preload 的关键区别是:

  • 资源按 module script 处理。
  • 浏览器会获取、解析并编译模块。
  • 模块会被放进模块映射中,后续 import 可以复用。
  • 默认 credentials mode 是 same-origin
  • 可以通过 crossorigin 调整 credentials mode。
  • 浏览器可以选择继续获取模块的依赖,但这在规范中是可选行为,不能假设所有浏览器都会递归预加载完整依赖图。

所以,最可靠的做法仍然是:构建工具或 SSR 框架显式输出当前页面需要的入口模块和关键依赖模块的 modulepreload

modulepreload 的 crossorigin 怎么理解#

一个常见误解是:modulepreload 会“自动 CORS”,所以完全不用写 crossorigin

更准确的说法是:module script 本身就按 CORS 规则加载,modulepreload 也遵循同一套 module script 相关的请求语义。

默认情况下,modulepreload 使用 same-origin credentials mode:

HTML
<link rel="modulepreload" href="/assets/main.js">
<script type="module" src="/assets/main.js"></script>

这对同源资源通常没问题。

如果你跨域加载模块,或者你的 <script type="module"> 使用了 crossorigin,就应该让两边保持一致:

HTML
<link rel="modulepreload" href="https://cdn.example.com/main.js" crossorigin>
<script type="module" src="https://cdn.example.com/main.js" crossorigin></script>

如果凭据模式不一致,仍然可能导致重复请求或加载失败。

modulepreload 能不能处理动态 import?#

modulepreload 主要处理静态模块资源。动态 import() 是运行时行为,浏览器在解析入口模块时未必能知道用户之后会点哪个按钮、打开哪个面板。

如果你知道某个动态模块马上会用到,可以主动插入:

TS
function preloadEditor() {
  const link = document.createElement("link");
  link.rel = "modulepreload";
  link.href = "/assets/editor.js";
  document.head.appendChild(link);
}

button.addEventListener("mouseenter", preloadEditor);

但如果只是“下一页可能会用到”,通常 prefetch 更合适,因为它优先级更低,不容易抢首屏资源。

性能对比#

假设模块依赖结构是:

TEXT
main.js
├── utils.js
│   └── helpers.js
└── renderer.js
    └── dom.js

没有预加载时,浏览器可能经历:

TEXT
T0: 请求 main.js
T1: main.js 下载并解析,发现 utils.js 和 renderer.js
T2: 请求 utils.js 和 renderer.js
T3: 依赖下载并解析,发现 helpers.js 和 dom.js
T4: 请求 helpers.js 和 dom.js
T5: 全部准备完成,开始执行

显式 modulepreload 关键模块后:

HTML
<link rel="modulepreload" href="/assets/main.js">
<link rel="modulepreload" href="/assets/utils.js">
<link rel="modulepreload" href="/assets/helpers.js">
<link rel="modulepreload" href="/assets/renderer.js">
<link rel="modulepreload" href="/assets/dom.js">
<script type="module" src="/assets/main.js"></script>

浏览器可以更早并行获取和准备这些模块,减少模块发现瀑布。

真实项目里不会手写这么多 link。通常由 Vite、React Router、Next、Astro、SvelteKit 等构建工具或框架根据 manifest 自动生成。

构建工具中的 modulepreload#

现代构建工具会自动插入或生成 modulepreload 信息。

Vite 的生产构建会对入口 HTML 注入 modulepreload,用于预加载入口 chunk 的直接依赖。它还提供 build.modulePreload 选项,用来控制 polyfill、依赖解析等行为。

典型输出类似:

HTML
<link rel="modulepreload" crossorigin href="/assets/vendor.d4e5f6.js">
<script type="module" crossorigin src="/assets/main.a1b2c3.js"></script>

SSR 框架则通常会根据当前 route matches 和构建 manifest 输出当前页面所需的 modulepreload。

以 React Router Framework Mode 为例,构建阶段会从 Vite manifest 收集 route module 及其 imports;SSR 阶段由 <Scripts /> 根据当前匹配路由输出对应的 modulepreload

preload vs modulepreload vs prefetch#

特性 preload modulepreload prefetch
主要用途 当前页面很快要用的通用资源 当前页面很快要用的 ES Module 未来导航或空闲时可能用的资源
优先级倾向 较高,取决于 as 较高,按 module script 处理 较低,通常空闲时获取
是否执行 / 应用 否,只准备模块
是否按模块语义处理
是否适合字体 通常否
是否适合 ESM 入口和依赖 不推荐 未来可能用时可考虑
CORS / credentials 需要自己匹配实际消费者 按 module script 语义,也要注意一致性 需要注意复用条件

选择建议:

  • 首屏字体、LCP 图片、关键 fetch 数据 → preload
  • 当前页面 hydration 或入口所需 ES modules → modulepreload
  • 下一页或未来交互可能用到的资源 → prefetch

浏览器支持#

preloadmodulepreload 都已被现代主流浏览器支持。对于不认识的 link relation,浏览器通常会安全忽略,不会导致页面报错。

不过“支持标签”和“所有细节行为完全一致”不是一回事。尤其是 modulepreload 是否递归获取依赖、资源优先级调度、fetchpriority 具体影响,都可能随浏览器版本和实现策略变化。

因此,性能优化不要只凭直觉:应该结合 DevTools Network、Performance、Lighthouse、WebPageTest 或真实用户监控数据验证。

实战检查清单#

使用 preload / modulepreload 前,可以问自己几个问题:

  1. 这个资源是不是当前页面首屏一定会用?
  2. 浏览器默认发现它是不是太晚?
  3. 它是否会和更关键的 CSS、字体、LCP 图片、主脚本争抢带宽?
  4. astypecrossoriginfetchpriority 是否和真实使用方式匹配?
  5. 是否有 Chrome “preloaded but not used” 警告?
  6. 这个资源是否已经被框架或构建工具自动处理?
  7. 对 ESM 来说,我 preload 的是当前页面需要的模块,还是未来页面才可能用的模块?

常见错误示例#

错误 1:preload CSS 但忘记真正应用#

HTML
<link rel="preload" href="/app.css" as="style">

这不会应用样式。需要:

HTML
<link rel="stylesheet" href="/app.css">

或者使用明确的 preload-onload 技巧,但那通常要谨慎评估兼容性和可维护性。

错误 2:字体 preload 漏掉 crossorigin#

HTML
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2">

更稳妥:

HTML
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>

错误 3:把未来页面资源都 modulepreload#

HTML
<link rel="modulepreload" href="/assets/settings-page.js">
<link rel="modulepreload" href="/assets/admin-page.js">
<link rel="modulepreload" href="/assets/editor-page.js">

如果这些页面不是当前页面马上要执行的 hydration 资源,就可能抢首屏带宽。更适合使用路由级 prefetch,或者等用户 hover / viewport 接近 / idle 时再加载。

小结#

preloadmodulepreload 都是在帮浏览器更早发现资源,但它们解决的是不同问题。

preload 是通用工具,适合当前页面很快要用、但默认发现较晚的字体、LCP 图片、关键 CSS 或 fetch 数据。

modulepreload 是 ES Module 专用工具,适合当前页面很快要用的模块入口和依赖。它按 module script 语义获取、解析和准备模块,可以减少模块发现瀑布。

prefetch 则更适合未来可能访问的页面或低优先级资源。

真正的最佳实践不是“多加几个 preload”,而是用构建产物、Network waterfall 和 Web Vitals 去判断:哪个资源发现太晚,哪个资源真的阻塞首屏,然后只对这些资源下手。

参考资料#

This browser prefers English.

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