エッセイ
理解 preload 与 modulepreload:资源预加载完全指南#
浏览器在解析 HTML 时会按顺序发现资源。但很多关键资源并不会第一时间出现在 HTML 里:字体藏在 CSS 的 @font-face 中,LCP 图片可能是 CSS 背景图,JavaScript 模块的深层依赖要等入口模块下载和解析后才会被发现。
<link rel="preload"> 和 <link rel="modulepreload"> 都是在解决“资源发现太晚”的问题。区别在于:
preload是通用资源预加载机制,适用于字体、图片、CSS、fetch 等资源。modulepreload是 ES Module 专用机制,除了下载模块文件,还会按模块语义处理和准备模块。
理解这两个标签的差异,可以避免两类常见问题:一类是该预加载的关键资源太晚发现,另一类是过度预加载导致首屏带宽被浪费。
<link rel="preload">:通用资源预加载#
preload 是一个声明式资源获取指令,意思是:这个资源当前页面很快会用到,请浏览器尽早开始请求。
常见写法:
<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 后,一般会:
- 立即发起资源请求。
- 根据
as、type、crossorigin、fetchpriority等信息决定优先级和请求模式。 - 把响应放入可复用的缓存位置。
- 等后续真正的消费者出现,例如
<img>、@font-face、fetch()、<link rel="stylesheet">。 - 如果请求模式匹配,就复用刚才的结果。
关键点是:preload 只负责提前获取,不负责实际使用。
例如:
<link rel="preload" href="/styles/critical.css" as="style">
这只会下载 CSS,不会把样式应用到页面。要应用样式,仍然需要:
<link rel="stylesheet" href="/styles/critical.css">
同理,预加载脚本不会执行脚本;预加载图片不会显示图片;预加载 JSON 不会自动把数据交给你的应用。
preload 的典型使用场景#
1. 预加载首屏字体#
字体通常在 CSS 中通过 @font-face 声明。浏览器要等 CSS 下载、解析,并确认页面上有文本实际需要这个字体后,才会请求字体文件。
如果这个字体影响首屏文本,可以显式 preload:
<link
rel="preload"
href="/fonts/inter-var.woff2"
as="font"
type="font/woff2"
crossorigin
>
字体 preload 通常需要 crossorigin。即使字体文件和页面同源,也要和实际字体请求的 CORS 模式保持一致,否则可能重复下载。
不过不要把所有字体都 preload。一般只预加载首屏真正会用到的 woff2,避免把粗体、斜体、多语言大字库全部塞进关键路径。
2. 预加载 LCP 图片#
如果 LCP 元素是一张很重要的 hero 图,尤其是 CSS 背景图,浏览器可能发现得很晚。
可以写:
<link
rel="preload"
href="/images/hero-banner.webp"
as="image"
fetchpriority="high"
>
如果是响应式图片,还可以配合 imagesrcset 和 imagesizes,让浏览器选择合适尺寸:
<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,可以考虑:
<link rel="preload" href="/api/bootstrap.json" as="fetch" crossorigin>
然后应用里正常 fetch:
await fetch("/api/bootstrap.json", { credentials: "same-origin" });
这里要特别注意 crossorigin 和 credentials 是否一致。请求模式不一致时,浏览器可能不会复用 preload 结果。
Fetch Priority 与 preload 的关系#
preload 会让资源更早进入请求队列,但优先级还会受到资源类型、浏览器策略和 fetchpriority 影响。
例如:
<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 发出类似警告:
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 不一样。它天然形成依赖图:
// main.js
import { helper } from "./utils.js";
import { render } from "./renderer.js";
浏览器加载模块入口时,必须:
- 下载入口模块。
- 解析入口模块,发现静态
import。 - 下载依赖模块。
- 解析依赖模块,继续发现更深层依赖。
- 等整个模块图完成后,再按模块规则执行。
如果依赖层级很深,就会出现模块发现瀑布:每一层都要等上一层下载和解析后才能发现。
为什么不应该用普通 preload 解决模块图问题#
你当然可以写:
<link rel="preload" href="/assets/main.js" as="script" crossorigin>
<script type="module" src="/assets/main.js" crossorigin></script>
这在某些情况下可能让入口文件更早下载。但它不是 ES Module 的专用机制,存在几个问题:
- 它不会按照 module script 的语义准备模块记录。
- 它不会帮你处理模块图里的静态依赖。
- 它要求你非常小心地匹配 CORS / credentials 模式。
- 它很容易只优化入口文件,却没有解决深层依赖瀑布。
因此,对于 ES Module,语义上更正确的做法是 modulepreload。
<link rel="modulepreload">:ES Module 专用预加载#
modulepreload 是为 module script 设计的预加载机制:
<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:
<link rel="modulepreload" href="/assets/main.js">
<script type="module" src="/assets/main.js"></script>
这对同源资源通常没问题。
如果你跨域加载模块,或者你的 <script type="module"> 使用了 crossorigin,就应该让两边保持一致:
<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() 是运行时行为,浏览器在解析入口模块时未必能知道用户之后会点哪个按钮、打开哪个面板。
如果你知道某个动态模块马上会用到,可以主动插入:
function preloadEditor() {
const link = document.createElement("link");
link.rel = "modulepreload";
link.href = "/assets/editor.js";
document.head.appendChild(link);
}
button.addEventListener("mouseenter", preloadEditor);
但如果只是“下一页可能会用到”,通常 prefetch 更合适,因为它优先级更低,不容易抢首屏资源。
性能对比#
假设模块依赖结构是:
main.js
├── utils.js
│ └── helpers.js
└── renderer.js
└── dom.js
没有预加载时,浏览器可能经历:
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 关键模块后:
<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、依赖解析等行为。
典型输出类似:
<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
浏览器支持#
preload 和 modulepreload 都已被现代主流浏览器支持。对于不认识的 link relation,浏览器通常会安全忽略,不会导致页面报错。
不过“支持标签”和“所有细节行为完全一致”不是一回事。尤其是 modulepreload 是否递归获取依赖、资源优先级调度、fetchpriority 具体影响,都可能随浏览器版本和实现策略变化。
因此,性能优化不要只凭直觉:应该结合 DevTools Network、Performance、Lighthouse、WebPageTest 或真实用户监控数据验证。
实战检查清单#
使用 preload / modulepreload 前,可以问自己几个问题:
- 这个资源是不是当前页面首屏一定会用?
- 浏览器默认发现它是不是太晚?
- 它是否会和更关键的 CSS、字体、LCP 图片、主脚本争抢带宽?
as、type、crossorigin、fetchpriority是否和真实使用方式匹配?- 是否有 Chrome “preloaded but not used” 警告?
- 这个资源是否已经被框架或构建工具自动处理?
- 对 ESM 来说,我 preload 的是当前页面需要的模块,还是未来页面才可能用的模块?
常见错误示例#
错误 1:preload CSS 但忘记真正应用#
<link rel="preload" href="/app.css" as="style">
这不会应用样式。需要:
<link rel="stylesheet" href="/app.css">
或者使用明确的 preload-onload 技巧,但那通常要谨慎评估兼容性和可维护性。
错误 2:字体 preload 漏掉 crossorigin#
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2">
更稳妥:
<link rel="preload" href="/fonts/inter.woff2" as="font" type="font/woff2" crossorigin>
错误 3:把未来页面资源都 modulepreload#
<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 时再加载。
小结#
preload 和 modulepreload 都是在帮浏览器更早发现资源,但它们解决的是不同问题。
preload 是通用工具,适合当前页面很快要用、但默认发现较晚的字体、LCP 图片、关键 CSS 或 fetch 数据。
modulepreload 是 ES Module 专用工具,适合当前页面很快要用的模块入口和依赖。它按 module script 语义获取、解析和准备模块,可以减少模块发现瀑布。
prefetch 则更适合未来可能访问的页面或低优先级资源。
真正的最佳实践不是“多加几个 preload”,而是用构建产物、Network waterfall 和 Web Vitals 去判断:哪个资源发现太晚,哪个资源真的阻塞首屏,然后只对这些资源下手。