modulepreload在现代前端项目中的实际应用与性能提升效果

令狐佼佼 优化 阅读 2,964
赞 15 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年底接手一个老项目重构,目标很朴素:把首页首屏加载时间从 3.2s 压到 1.8s 以内。不是什么高大上的 SPA,就是个纯静态 HTML + 一堆 <script type="module"> 的现代站点,用 Vite 打包,但没上 SSR。上线前跑 Lighthouse,JS 加载阻塞问题特别明显——浏览器解析完 HTML 后,得等 main.js 下载、解析、执行完,才开始拉 header.jshero-carousel.js 这些模块依赖。Network 面板里那几条蓝色(script)请求排成一列,像早高峰地铁闸机口。

modulepreload在现代前端项目中的实际应用与性能提升效果

当时第一反应是 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.jsanalytics.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(&lt;link rel=&quot;modulepreload&quot; href=&quot;${manifest[imp].file}&quot;&gt;);
            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(&#039;&#039;)}${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 兼容性这块,我也还在摸索中。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论