先记住这个答案
只执行一次。浏览器和 Node.js 都维护一张模块映射表,键是模块解析后的完整 URL。第一次遇到某个 URL 时,宿主加载、链接并执行它,把结果存进表里;之后别的模块再导入同一个 URL,直接复用这个实例,顶层代码不会重跑。所以多个导入方拿到的导出是同一份,模块顶层天然适合放单例。
- 同一 URL 的模块只执行一次,所有导入方共享一个实例
- 缓存的键是解析后的完整 URL,不是你写的字符串
- 加查询参数或路径不同,会被当成另一个模块再执行
- 模块顶层很适合放单例:连接池、store、事件总线
三个模块导入同一个文件
在 Node.js 里写三个文件:counter.mjs 在顶层打一行日志,a.mjs 和 main.mjs 都导入它。
// counter.mjs
console.log('counter 执行了')
export const store = { count: 0 }
// a.mjs
import { store } from './counter.mjs'
store.count++
// main.mjs
import './a.mjs'
import { store } from './counter.mjs'
import * as again from './counter.mjs'
console.log(store.count) // 1,a.mjs 改的就是这一份
console.log(again.store === store) // true
// 运行 node main.mjs,「counter 执行了」只打印一次a.mjs 和 main.mjs 各导入了一次,main.mjs 还用两种写法导入了两次,但日志只有一行。a.mjs 里对 store 的修改,main.mjs 里读得到,因为它们拿到的是同一个对象。
什么情况下会执行第二次
既然缓存按 URL 区分,那 URL 变了,就是另一个模块。
- URL 不同就是不同的模块
- 最常见的是加查询参数:
import("./counter.mjs?v=2")会让counter.mjs再执行一次,得到一个全新的store,和原来那份互不影响。开发时有人故意用这招绕过缓存,重新加载配置。 另外几种情况:同一个库通过 CDN 地址和本地路径各引了一次;项目依赖里装了同一个包的两个版本,路径分别在node_modules/a/node_modules/x和node_modules/x;打包时同一个模块被分进了两个互相独立的产物。这些都会得到两个实例。React 报「Invalid hook call」,有一类原因就是页面里有两份 React。 Node.js 里还有一点:同一个包如果同时被require和import,而包给 CommonJS 和 ESM 配了不同的入口文件,那就是两个文件、两份状态,这就是常说的双包风险。
容易答错的地方
- 以为动态
import()每次都会重新执行 import()和静态import共用同一张模块映射表。第一次调用会加载执行,之后再调用同一个 URL,返回的 Promise 直接用缓存的实例解决,不会重跑顶层代码。- 以为删掉缓存就能热更新
- CommonJS 可以
delete require.cache[path]让模块重新加载,ESM 没有公开的 API 可以清除模块映射表。Vite 这类工具的热更新是自己给 URL 加时间戳参数,让浏览器当成新模块去加载,旧实例其实还在。
面试官还会怎么问?
CommonJS 也是只执行一次吗?
也是。require 会把模块按解析后的文件路径缓存在 require.cache 里,第二次 require 直接返回缓存的 module.exports。区别是 CommonJS 的缓存可以手动删,而且缓存的是导出对象,ESM 缓存的是整个模块实例和它的实时绑定。
循环依赖时模块会不会被执行两次?
不会。A 导入 B、B 又导入 A 时,宿主发现 A 已经在映射表里、正在处理中,就不会重新执行 A,而是让 B 直接拿到 A 的绑定。代价是 B 执行时 A 可能还没初始化完,访问 A 里用 let、const 声明的导出会抛 ReferenceError。
为什么说模块适合写单例?
模块顶层只跑一次,导出的对象全局只有一份,不需要自己写 getInstance 这类判断。比如在 db.js 顶层创建数据库连接池并导出,所有业务模块导入的都是同一个池。前提是确保整个应用只加载了这个模块的一份。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。