エッセイ
理解 preload 与 modulepreload:资源预加载完全指南#
浏览器在解析 HTML 时会按顺序发现资源。但很多关键资源(字体、CSS、脚本)隐藏在 CSS 或 JavaScript 中,等浏览器发现它们时已经太晚了。<link rel="preload" /> 和 <link rel="modulepreload" /> 提供了一种机制,让开发者可以提前告知浏览器哪些资源是重要的。
<link rel="preload" />:通用资源预加载#
preload1 是一个声明式的资源获取指令,告诉浏览器"这个资源很快就会用到,请尽早开始下载"。
基本语法#
<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="/scripts/analytics.js" as="script">
<link rel="preload" href="/images/hero.webp" as="image">
as 属性是必须的2,它告诉浏览器资源的类型,影响请求优先级和缓存策略。常用值包括:
as 值 |
资源类型 | 示例 |
|---|---|---|
script |
JavaScript 文件 | 分析脚本、第三方库 |
style |
CSS 样式表 | 关键样式、字体样式 |
font |
字体文件 | woff2、woff |
image |
图片 | hero 图、logo |
fetch |
XHR/Fetch 请求 | API 数据、JSON |
典型使用场景#
预加载字体:字体通常在 CSS 中通过 @font-face 声明,浏览器要等到 CSSOM 构建完成且发现需要该字体的文本时才会请求。preload 可以提前触发下载:
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
注意字体预加载必须添加 crossorigin,即使是同源资源——因为字体请求总是使用 CORS。
预加载关键 CSS:对于通过 JavaScript 动态加载的样式,可以用 preload 提前获取:
<link rel="preload" href="/styles/above-fold.css" as="style">
预加载 LCP 图片3:如果最大内容绘制(LCP)元素是一张图片,尤其是 CSS 背景图,preload 可以显著提升 LCP 时间:
<link rel="preload" href="/images/hero-banner.webp" as="image" fetchpriority="high">
preload 的工作原理#
浏览器收到 preload 指令后会:
- 立即发起资源请求,不等待正常的资源发现流程。
- 将响应存入内存缓存。
- 等待资源被实际使用(如
<script>标签引用、CSS@import等)。 - 从缓存中提供资源,无需再次请求。
关键点:preload 只负责获取资源到缓存,不会执行脚本或应用样式。资源的实际使用由后续的标签触发。
Fetch Priority#
preload 请求的网络优先级由 as 属性决定,不同资源类型有默认优先级:
as 值 |
默认优先级 |
|---|---|
style |
Highest |
font |
High |
script |
High(或 Medium,取决于位置) |
image |
Low(视口内为 High) |
可以用 fetchpriority 属性在默认值基础上微调:
<!-- 提升 LCP 图片的优先级 -->
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<!-- 降低非关键脚本的优先级 -->
<link rel="preload" href="/analytics.js" as="script" fetchpriority="low">
preload 的注意事项#
需要在短时间内使用:如果预加载的资源在页面加载后短时间内没有被使用,Chrome 会在控制台发出警告。这表明预加载可能是不必要的,浪费了带宽。
The resource https://example.com/font.woff2 was preloaded using link preload but not used within a few seconds from the window's load event.
不要预加载过多资源:preload 让资源更早进入请求队列,可能与其他关键资源竞争带宽。只预加载真正影响首屏渲染的资源。
CORS 匹配:preload 请求的 CORS 模式必须与实际使用时一致,否则会导致重复请求。
ES 模块的加载问题#
传统 <script> 标签加载的脚本是独立的——浏览器下载、解析、执行,整个过程相对直接。但 ES 模块不同,它们形成依赖图谱:
// main.js
import { helper } from './utils.js';
import { render } from './renderer.js';
// renderer.js 又可能依赖其他模块...
浏览器加载 ES 模块时必须:
- 下载入口模块。
- 解析模块,发现
import语句。 - 下载被导入的模块。
- 递归解析这些模块,发现更多依赖。
- 继续下载、解析,直到整个依赖图完成。
这就是所谓的模块解析瀑布(module resolution waterfall)——浏览器必须按顺序发现并加载依赖,无法提前知道完整的依赖图。对于深层嵌套的依赖,每一层都增加一次网络往返延迟。
为什么 ES 模块不能用 preload?#
既然 preload 可以预加载脚本,为什么不用它来预加载 ES 模块?
preload 只负责下载:它把文件拉到浏览器缓存中,但不会进行模块特有的处理——解析模块语法、分析依赖图、准备模块执行环境。
模块解析仍然串行:即使文件已被 preload 缓存,当 <script type="module"> 执行时,浏览器仍需要从头开始模块解析流程——读取文件、分析 import、发现依赖、再请求依赖。瀑布效应并没有消除。
CORS 模式不匹配:ES 模块总是以 CORS 模式请求。如果 preload 没有正确设置 crossorigin 属性,浏览器会认为这是两个不同的请求,导致资源被重复下载:
<!-- 错误:CORS 模式不匹配,导致重复请求 -->
<link rel="preload" href="module.js" as="script">
<script type="module" src="module.js"></script>
<!-- 正确设置 crossorigin,但仍不理想 -->
<link rel="preload" href="module.js" as="script" crossorigin>
<script type="module" src="module.js"></script>
即使 CORS 设置正确,preload 也只能避免入口模块的重复下载,对于模块的依赖链无能为力。
<link rel="modulepreload" />:ES 模块专用预加载#
modulepreload4 是专为 ES 模块设计的预加载机制:
<link rel="modulepreload" href="./utils.js">
<link rel="modulepreload" href="./renderer.js">
<script type="module" src="./main.js"></script>
完整的模块处理:不仅下载文件,还会解析模块、构建模块图、编译代码并准备好执行。当脚本真正需要这些模块时,它们已经处于可执行状态。
自动 CORS 处理:modulepreload 默认使用与 ES 模块相同的 CORS 设置,无需手动添加 crossorigin。
依赖预获取:浏览器可以选择性地预获取被 modulepreload 的模块的依赖(这是可选行为,具体取决于浏览器实现)。
仅限静态导入:modulepreload 只解析静态 import 声明,不会处理动态 import()。动态导入是运行时行为,浏览器在解析阶段无法预知。如需预加载动态导入的模块,必须手动添加 <link rel="modulepreload">。
性能对比#
考虑以下依赖结构:
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="./main.js">
<link rel="modulepreload" href="./utils.js">
<link rel="modulepreload" href="./helpers.js">
<link rel="modulepreload" href="./renderer.js">
<link rel="modulepreload" href="./dom.js">
<script type="module" src="./main.js"></script>
所有模块并行下载和预处理,消除了瀑布效应。
实际应用#
构建工具集成#
现代构建工具会自动注入 modulepreload 标签。
Vite 默认为生产构建生成 modulepreload 链接:
<!-- Vite 生成的 HTML -->
<link rel="modulepreload" crossorigin href="/assets/main.a1b2c3.js">
<link rel="modulepreload" crossorigin href="/assets/vendor.d4e5f6.js">
<script type="module" crossorigin src="/assets/main.a1b2c3.js"></script>
Rollup 可以通过插件实现类似功能。
服务端渲染(SSR)#
SSR 框架通常在渲染时生成 modulepreload 标签:
// 服务端渲染时注入预加载
function renderHTML(manifest: ModuleInfo[]) {
return `
<head>
${manifest.map(m => `<link rel="modulepreload" href="${m.href}">`).join('\n')}
</head>
`;
}
动态导入的预加载#
对于使用 import() 动态导入的模块,可以在用户可能需要之前预加载:
// 用户悬停在按钮上时预加载
function preloadEditor() {
const link = document.createElement('link');
link.rel = 'modulepreload';
link.href = '/editor.js';
document.head.appendChild(link);
}
button.addEventListener('mouseenter', preloadEditor);
preload vs modulepreload vs prefetch#
| 特性 | preload |
modulepreload |
prefetch |
|---|---|---|---|
| 优先级 | 高 | 高 | 低(空闲时) |
| 模块解析 | ❌ | ✅ | ❌ |
| CORS 处理 | 手动 | 自动 | 手动 |
| 适用场景 | 当前页面的关键资源 | ES 模块 | 未来导航可能用到的资源 |
| 使用时机 | 首屏渲染关键路径 | 模块脚本入口和依赖 | 下一页面的资源 |
选择指南:
- 字体、关键 CSS、LCP 图片 →
preload - ES 模块脚本 →
modulepreload - 下一页面的资源、非关键路径资源 →
prefetch
浏览器支持#
modulepreload 已被所有现代浏览器支持,详见 Can I Use。对于不支持的浏览器,标签会被安全忽略,不会造成错误。
注意事项#
不要过度使用:只预加载关键路径上的模块。预加载过多资源会与其他关键资源竞争带宽。
考虑缓存:对于已缓存的模块,modulepreload 的收益会降低。配合合理的缓存策略使用。
动态导入的权衡:对于按需加载的模块,使用 prefetch 可能比 modulepreload 更合适——前者优先级较低,不会影响首屏加载。
preload 和 modulepreload 都是资源预加载工具,但解决的问题不同。preload 适用于字体、样式、图片等通用资源;modulepreload 则是 ES 模块的专属方案,解决模块依赖图带来的加载瀑布问题。理解两者的区别,才能在正确的场景使用正确的工具。