在优化 JavaScript 模块加载性能时,modulepreload 是一个常被误解的工具。许多人误以为它能直接加速模块执行,实际上它的作用仅限于提前请求网络资源。然而,一旦路径填写错误、遗漏 crossorigin 属性,或者未列出所有依赖子模块,预加载操作便会完全失效。

modulepreload 与 defer/async 的根本区别在哪里
一个常见的问题是:添加了 modulepreload 之后,模块执行是否会更快?实际上,它完全不参与执行流程。浏览器仅执行 fetch 操作,不会解析、构建模块图或执行任何代码。核心差异如下:
defer:等待 DOM 解析完成后执行,但下载时机由解析器控制,可能延迟首屏渲染。async:下载完成后立即执行,但会打乱模块顺序,对于存在 import 依赖关系的模块链而言,这种执行方式不可接受。modulepreload:唯一任务是在 HTML 解析早期发起 fetch,将模块及其显式声明的依赖存入缓存,等到后续出现时再真正进行解析和执行。
简而言之,defer 负责控制执行时机但不会预下载;async 异步下载并立即执行,但会破坏依赖顺序;而 modulepreload 仅在 HTML 解析早期执行 fetch 任务,不进行解析、执行或模块图构建。
为什么写了 modulepreload 却没生效
常见的静默失效情况是控制台不报错,Network 面板也无请求,问题通常出在以下几个关键条件上:
href属性值必须与后续的src完全一致,包括协议、域名、路径以及查询参数(例如?v=1.2.3),一处都不可有差异。- 只要模块通过 CDN 或跨域服务加载(即使是静态托管平台),
标签必须包含crossorigin属性,否则浏览器会直接忽略预加载。 - 不支持相对路径,如
./main.mjs或../utils.js会被跳过,必须使用绝对路径或根相对路径(例如/assets/main.mjs)。 modulepreload不会递归加载子模块,例如main.mjs中import './utils.js',你需要手动添加一行。
怎么写一个真正有效的 modulepreload 链
手动编写容易遗漏且难以维护,特别是在构建产物包含哈希值的情况下。不过,理解规则有助于调试:
- 多个
标签建议按照执行依赖顺序排列,例如先vendor.mjs后main.mjs,部分浏览器会在同一 TCP 连接中复用资源。 - 每个
href必须指向最终解析后的 URL,例如 Vite 构建后为/assets/main.a1b2c3.js,而不是src/main.js。 - 不可用于非模块脚本,比如为
type="text/ja vascript"的文件添加modulepreload会静默失败。 - 必须放置在
中,且要早于第一个;若放在中则无效。
实际项目中应避免手动编写——Vite 默认启用 build.modulePreload,Webpack 可通过 webpack-preload-plugin 配置,Rollup 推荐使用 rollup-plugin-module-preload。这些工具能够静态分析 AST,提取所有静态 import 路径并注入完整的预加载列表。
最容易忽视的一点是,modulepreload 仅对静态 import 生效,动态 import() 以及通过 import map 映射的 bare specifier 不在其作用范围内。此外,即使预加载成功,如果模块存在语法错误、路径拼写错误或受到 CSP 拦截等执行期问题,仍然会导致 Failed to load module script 错误,这与预加载本身无关。
