模块缓存是javascript引擎维护的独立url映射表,以完全解析后的绝对url为键、模块记录为值,确保同一url只执行一次且不可逆;它与http缓存无关,绕过需变更url。

模块脚本的依赖文件在浏览器中不是“普通缓存”,而是一套独立、严格、基于 URL 的模块缓存(Module Map)机制——它不随 HTTP 缓存头变化,也不受 localStorage 或 Service Worker 直接覆盖,而是由 JavaScript 引擎在内存中维护的单例映射表。
模块缓存的本质是 URL → 模块记录的强绑定
浏览器为每个标签页维护一个全局模块缓存(Module Map),它的 key 是模块被**完全解析后的绝对 URL**(例如 https://cdn.example.com/utils.js?v=2.1.0),value 是该模块对应的 模块记录(ModuleRecord),包含静态导入声明、导出绑定、执行状态等元信息。只要两次 import 的 URL 字符串完全一致(含协议、域名、路径、查询参数),就命中同一缓存项;哪怕文件内容已更新,只要 URL 不变,浏览器仍复用旧的 ModuleRecord,且模块代码**绝不会重新执行**。
这意味着:
- 同一模块被多个地方 import,只下载一次、只解析一次、只执行一次
- import './a.js' 和 import './a.js?v=1' 被视为两个不同模块,各自缓存、各自执行
- 即使服务器返回 Cache-Control: no-cache,模块缓存依然生效——因为它是 JS 引擎层机制,与网络层缓存无关
缓存生命周期:从 fetching 到 instantiated,不可逆
一个模块在缓存中的状态流转严格遵循加载阶段:
- fetching:URL 解析完成,开始网络请求时写入缓存,value 临时标记为 fetching(防止并发重复请求)
- parsed:脚本下载完成,静态解析 import/export 语句,生成 ModuleRecord 并填入缓存
- instantiated:所有依赖模块就绪后,建立导出绑定(export binding),但尚未执行顶层代码
- evaluated:执行模块顶层代码,导出对象正式就位;此后任何 import 都直接返回该结果
这个过程不可回退。一旦进入 evaluated 状态,刷新页面前无法“清空”该模块缓存——即使你动态删除 script 标签或调用 location.reload(),只要 URL 不变,缓存仍保留。
如何真正绕过或更新模块缓存?
浏览器不提供 clearModuleCache API,开发者只能通过 URL 层面触发新缓存项:
-
版本化路径:构建时重命名文件(
utils.a8f3b2d.js)或添加稳定哈希查询参数(utils.js?hash=9e8c2a) -
强制刷新上下文:使用
<link rel="modulepreload">配合不同 URL 预加载新版本,再在后续<script type="module" src="..."></script>中引用该新 URL - 避免本地开发陷阱:file:// 协议下模块加载被禁用,必须用本地服务器(如 vite preview、http-server);否则连 fetching 阶段都无法进入,更无缓存可言
和传统脚本缓存的关键区别
经典 script(无 type="module")的缓存行为由 HTTP 响应头(如 ETag、Last-Modified)控制,执行时直接运行全局代码;而模块缓存是 JS 引擎内存结构,与网络响应分离。即使服务端返回 304 Not Modified,模块仍可能因 URL 未变而复用已执行的导出对象;反之,若服务端返回 200 但内容变更,只要 URL 不变,模块代码也不会重跑——这正是模块“单例性”的底层保障,也是热更新需配合 HMR 工具链的原因。











