modulepreload在现代前端项目中的实际应用与性能提升效果
项目初期的技术选型
去年底接手一个老项目重构,目标很朴素:把首页首屏加载时间从 3.2s 压到 1.8s 以内。不是什么高大上的 SPA,就是个纯静态 HTML + 一堆 <script type="module"> 的现代站点,用 Vite 打包,但没上 SSR。上线前跑 Lighthouse,JS 加载阻塞问题特别明显——浏览器解析完 HTML 后,得等 main.js 下载、解析、执行完,才开始拉 header.js、hero-carousel.js 这些模块依赖。Network 面板里那几条蓝色(script)请求排成一列,像早高峰地铁闸机口。
当时第一反应是 code split,但 vite 已经自动做了,再拆意义不大。第二反应是 <link rel="preload">,试了下,发现对 type="module" 的脚本不起作用——浏览器根本不认,preload 了也白 preload,照样等主模块执行完才去 fetch 依赖。查文档翻到一半,看到一行小字:rel="modulepreload"。好家伙,这玩意儿我三年前在 Chrome 63 的 release note 里见过,当时觉得“谁会真用”,现在它居然成了救命稻草。
最大的坑:性能问题
一开始以为很简单:把关键模块路径丢进 <link rel="modulepreload"> 就完事。写了段脚本自动读取 manifest.json,生成 preload 标签插进 <head>。上线后 Lighthouse 分数没升反降,FCP 慢了 200ms。折腾半天发现是顺序问题——我把 modulepreload 放在了 <script type="module" src="/assets/main.js"> 后面。浏览器:你让我预加载?可我还没看到 main.js 啊,根本不知道它要 import 谁。结果 preload 请求被当成普通资源,优先级降档,甚至部分被取消。
必须前置!而且不能只靠 JS 动态插入。最后硬塞进了 HTML 模板的 <head> 最顶部,紧贴 <meta charset> 后面。还有个更隐蔽的坑:重复 preload。比如 main.js 和 analytics.js 都 import 了 utils.js,如果两个都写 preload,Chrome 会发两次请求(DevTools Network 里能看到两个 utils.js,status 是 200,不是 304)。查了源码,modulepreload 不支持 dedupe,得自己去重。我写了个简单哈希表:
<link rel="modulepreload" href="/assets/utils.abc123.js">
<link rel="modulepreload" href="/assets/header.def456.js">
<link rel="modulepreload" href="/assets/hero-carousel.ghi789.js">
注意路径必须和实际 import 的完全一致(带 hash、带扩展名),少个点都不行。我们项目用的是 Vite 默认的 [name].[hash].js,所以生成时必须严格匹配输出文件名,不能手写 utils.js 然后指望它自动找 hash 版本。
最终的解决方案
核心逻辑就三步:构建时生成 modulepreload 清单 → 注入 HTML head → 上线后监控效果。Vite 插件搞定了前两步。重点是注入时机:不能用 vite-plugin-html 的 transformIndexHtml 钩子,因为那个阶段 manifest.json 还没写入磁盘。改用 generateBundle 钩子,在打包完成瞬间读 manifest,生成 link 标签字符串,然后通过 fs.writeFileSync 直接写进 dist/index.html。代码片段如下(精简版):
// vite.config.js
export default defineConfig({
plugins: [{
name: 'modulepreload-injector',
generateBundle(_, bundle) {
if (!bundle['index.html']) return;
const manifest = JSON.parse(
fs.readFileSync('dist/.vite/manifest.json', 'utf-8')
);
const preloadLinks = [];
const seen = new Set();
// 从入口开始 DFS 找所有静态 import 的模块
const crawl = (file) => {
const chunk = manifest[file];
if (!chunk || !chunk.imports) return;
for (const imp of chunk.imports) {
if (!seen.has(imp)) {
seen.add(imp);
preloadLinks.push(<link rel="modulepreload" href="${manifest[imp].file}">);
crawl(imp);
}
}
};
crawl('index.html');
const html = fs.readFileSync('dist/index.html', 'utf-8');
const injectPos = html.indexOf('</head>');
const newHtml = ${html.slice(0, injectPos)}${preloadLinks.join('')}${html.slice(injectPos)};
fs.writeFileSync('dist/index.html', newHtml);
}
}]
});
这里有个取巧:没做完整的 AST 解析,而是信任 Vite 的 manifest.imports 字段(实测 95% 场景准确)。动态 import(import('./foo.js')) 的模块不处理——反正它们本来就不会阻塞首屏,preload 了反而占带宽。
上线后对比数据:FCP 从 1.62s → 1.18s,Lighthouse Performance 分数 68 → 89。最明显的是 Network 面板里,那些原本排队的蓝色 script 请求,现在全变成浅绿色(prefetch/modulepreload),几乎同时发起,且多数在 DOMContentLoaded 前就完成了。
回顾与反思
做得好的地方:预加载路径 100% 匹配、注入时机精准、去重逻辑轻量。最大的收益其实是心理层面的——以前看 Network 面板总焦虑,现在一眼扫过去全是并行请求,踏实多了。
没做好的地方也有:目前只处理了 .js 模块,但项目里其实有少量 import('./data.json', { assert: { type: 'json' } }),这部分没加 preload,浏览器还是得等 main.js 执行到那行才去 fetch。理论上可以扩展,但 JSON 文件通常很小,优先级低,我就先放着了。另一个问题是 Safari 支持 modulepreload 是从 16.4 开始的,而我们最低支持 16.0,所以加了 UA 判断,只给 Chrome/Firefox/Edge ≥110 的用户下发。Safari 用户就老老实实走默认流程,影响不大。
最后说句实在话:modulepreload 不是银弹。它解决的是「资源发现时机晚」的问题,而不是「资源太大」的问题。如果你的 main.js 本身有 800KB,preload 只会让它更快地卡住主线程。我们后来还是砍掉了两个第三方 chart 库,换成更轻量的方案——preload 是加速器,不是减肥药。
以上是我踩坑后的总结,希望对你有帮助。这个方案不是最优解(比如 Webpack 的 Module Federation 自带 preload 优化),但对 Vite + 简单静态站来说,够用、可控、不引入新复杂度。有更优的实现方式欢迎评论区交流,尤其是 Safari 兼容性这块,我也还在摸索中。

暂无评论