重新理解 ESM:彻底搞懂 JavaScript 模块机制
- # javascript
- # esm
- # module
ESM(ECMAScript Modules)我们每天都在用:
import { ref } from 'vue'
import Layout from '../layouts/Layout.astro'
export const siteTitle = 'Hok Keung'
但很多时候,我们对它的理解停留在「另一种写法」:把 require 换成 import,把 module.exports 换成 export。这会导致一些问题不好解释:
- 为什么
import必须写在顶层? - 为什么循环依赖在 ESM 里有时能工作,有时又会炸?
- 为什么改了一个导出的变量,其他模块能看到最新值?
- 为什么
import()可以动态加载,而import ... from ...不行? - 为什么构建工具可以基于 ESM 做 tree shaking?
这篇文章试着重新整理 ESM 的心智模型:ESM 不是一套运行时拷贝值的语法,而是一套在执行前就能建立模块图、绑定关系和执行顺序的机制。
先把模块看成一张图
一个模块文件不是孤立执行的。只要它包含静态 import,JavaScript 引擎或宿主环境(浏览器、Node.js、构建工具)就能在执行代码前知道它依赖哪些模块。
// main.js
import { render } from './render.js'
import { createStore } from './store.js'
const store = createStore()
render(store)
这段代码会形成一张模块图:
main.js
├── render.js
└── store.js
如果 render.js 又依赖 dom.js,图会继续展开:
main.js
├── render.js
│ └── dom.js
└── store.js
ESM 的核心优势来自这件事:依赖关系是静态的。
静态不是说模块内容不会变化,而是说 import 和 export 的结构必须在源码解析阶段就能确定。下面这种写法在 ESM 里不成立:
if (isDev) {
import { debug } from './debug.js'
}
因为引擎不能等到运行 if 的时候才知道模块图。静态 import 必须在顶层,不能放进条件、循环、函数里。
需要运行时决定加载什么模块时,应该使用动态导入:
if (isDev) {
const { debug } = await import('./debug.js')
debug()
}
这两种导入的心智模型完全不同:
| 写法 | 发生时机 | 作用 |
|---|---|---|
import { x } from './a.js' | 模块执行前 | 参与静态模块图 |
import('./a.js') | 运行到这一行时 | 异步加载一个模块 |
ESM 的三个阶段
理解 ESM,最好不要从「某一行代码怎么执行」开始,而是从整个模块图的生命周期开始。
大致可以分成三个阶段:
解析 / 加载 → 链接 → 执行
不同宿主环境的「加载」细节不一样。浏览器按 URL 请求模块,Node.js 有自己的文件扩展名、package.json、exports 等解析规则,构建工具还可能处理别名和虚拟模块。但进入 JavaScript 语义之后,关键阶段是链接和执行。
1. 解析和加载:找到模块
当引擎看到:
import { foo } from './foo.js'
它首先要把 './foo.js' 解析成一个具体模块。
在浏览器里,相对路径会基于当前模块 URL 解析:
https://example.com/app/main.js
import './foo.js'
→ https://example.com/app/foo.js
在 Node.js 里,规则更复杂一些:
./foo.js、../foo.js是相对路径。/foo.js是绝对路径。vue、astro这类 bare specifier 会走包解析。.mjs通常按 ESM 处理。.cjs通常按 CommonJS 处理。.js取决于最近的package.json里的"type"字段。
这些都属于宿主环境的职责。ESM 规范关心的是:加载完成后,每个模块会被解析成一个 Module Record,里面记录了它导入什么、导出什么、包含哪些顶层声明。
2. 链接:建立绑定关系
链接阶段不会执行模块里的业务代码。它做的是更底层的事情:把每个 import 指向对应的 export。
// counter.js
export let count = 0
export function inc() {
count += 1
}
// main.js
import { count, inc } from './counter.js'
console.log(count)
inc()
console.log(count)
在链接阶段,main.js 里的 count 会被绑定到 counter.js 里导出的 count。注意这里的关键词是 绑定,不是复制。
这也是 ESM 和很多人直觉不一样的地方:import 得到的不是导出值的一份快照,而是一个指向原始绑定的只读视图。
3. 执行:按依赖顺序运行一次
链接完成后,模块才开始执行。
执行顺序一般是:先执行依赖,再执行依赖它的模块。
// a.js
console.log('a')
export const a = 1
// b.js
import { a } from './a.js'
console.log('b', a)
// main.js
import './b.js'
console.log('main')
输出顺序是:
a
b 1
main
同一个模块在同一个模块图中只会执行一次。即使它被多个模块导入,后续导入拿到的也是同一个模块实例里的绑定。
import 是 live binding,不是值拷贝
这是 ESM 最重要、也最容易被误解的机制。
看这个例子:
// counter.js
export let count = 0
export function inc() {
count += 1
}
// main.js
import { count, inc } from './counter.js'
console.log(count) // 0
inc()
console.log(count) // 1
如果 import 是值拷贝,第二次 console.log(count) 应该还是 0。但实际是 1,因为 count 是 live binding。
不过,从导入方看,这个绑定是只读的:
import { count } from './counter.js'
count += 1 // TypeError: Assignment to constant variable.
导入方不能重新赋值,但导出方可以改变原始绑定:
// counter.js
export let count = 0
export function reset() {
count = 0
}
这解释了一个很常见的现象:ESM 里可以把状态导出出去,但真正应该修改状态的地方仍然应该留在模块内部,通过函数暴露受控的更新入口。
export default 并不特殊到哪里去
default 很容易被理解成「这个模块本身导出的东西」。其实从模块系统角度看,它只是一个名字叫 default 的导出。
下面两种导入写法:
import Button from './Button.js'
import { default as Button } from './Button.js'
在语义上指向的是同一个导出名:default。
所以这段代码:
// Button.js
export default function Button() {}
可以这样导入:
import Button from './Button.js'
也可以这样转发:
export { default as Button } from './Button.js'
真正需要注意的是,default 只是导出名,不等于文件名,也不等于模块对象。你可以把它导入成任意本地变量名:
import AnyName from './Button.js'
这就是为什么大型项目里有人偏好具名导出:名字在导出方和导入方更容易保持一致,重构时也更清晰。当然,默认导出在页面组件、单个主函数、框架约定入口里依然很自然。
循环依赖为什么有时能工作
CommonJS 时代,循环依赖经常让人头疼。ESM 并没有消灭循环依赖,但它用 live binding 和链接阶段让一部分循环依赖变得可处理。
看一个能工作的例子:
// a.js
import { b } from './b.js'
export const a = 'a'
export function printB() {
console.log(b)
}
// b.js
import { a } from './a.js'
export const b = 'b'
export function printA() {
console.log(a)
}
这里 a.js 和 b.js 相互导入,但导入的值没有在模块初始化过程中立刻读取,而是在函数调用时才读取。只要两个模块都执行完,再调用 printA() 或 printB(),绑定已经初始化好了。
问题出现在「初始化期间读取对方还没初始化的绑定」:
// a.js
import { b } from './b.js'
export const a = b + 1
// b.js
import { a } from './a.js'
export const b = a + 1
这类代码通常会触发类似下面的错误:
ReferenceError: Cannot access 'a' before initialization
原因不是 ESM 不知道 a 存在。链接阶段已经知道 a 存在了。问题是执行阶段里,a 的绑定还处在 temporal dead zone(TDZ)中,没有完成初始化。
所以循环依赖的判断标准不是「有没有互相 import」,而是:
模块初始化期间,是否读取了对方尚未初始化的绑定。
可以工作的循环依赖,通常有一个共同点:导入的东西是函数、类型、配置注册入口,真正读取发生在初始化之后。容易出问题的循环依赖,通常是在顶层就互相计算值。
顶层 await 会改变执行节奏
ESM 支持 top-level await:
// config.js
export const config = await loadConfig()
这很方便,但它也会改变模块图的执行节奏。
如果一个模块依赖 config.js:
// main.js
import { config } from './config.js'
startApp(config)
那么 main.js 必须等待 config.js 的顶层 await 完成后才能继续执行。也就是说,top-level await 会把依赖它的模块也变成异步等待链的一部分。
这不是坏事,但需要有意识地使用。适合放在顶层的通常是「模块没有它就无法正确初始化」的依赖,例如启动前必须加载的配置。对于普通交互、按需功能、可延迟资源,import() 或函数内部的 await 往往更合适。
ESM 和 CommonJS 的核心差异
CommonJS 的模型更像「执行一个函数,返回一个对象」:
// counter.cjs
let count = 0
function inc() {
count += 1
}
module.exports = { count, inc }
// main.cjs
const { count, inc } = require('./counter.cjs')
console.log(count) // 0
inc()
console.log(count) // 0
这里的 count 是解构出来的值。inc() 修改了 counter.cjs 内部的变量,但 main.cjs 里的本地变量 count 不会自动更新。
当然,如果导出的是对象引用,CommonJS 也能观察到对象内部属性变化:
// state.cjs
const state = { count: 0 }
function inc() {
state.count += 1
}
module.exports = { state, inc }
const { state, inc } = require('./state.cjs')
inc()
console.log(state.count) // 1
所以差异不在于「CommonJS 永远不是响应式的」,而在于:
| 机制 | CommonJS | ESM |
|---|---|---|
| 依赖发现 | 运行到 require() 时 | 解析源码时 |
| 加载方式 | 同步 | 静态导入参与异步模块图,动态导入返回 Promise |
| 导出模型 | module.exports 对象 | 模块绑定集合 |
| 导入值 | 普通变量或对象引用 | live binding 的只读视图 |
| 静态分析 | 困难 | 天然支持 |
这也是为什么构建工具喜欢 ESM。它能在执行前知道:
- 这个模块导出了哪些名字。
- 另一个模块导入了哪些名字。
- 哪些导出完全没有被使用。
- 哪些模块只有副作用,不能随便删除。
Tree shaking 并不是「用了 ESM 就自动删掉所有没用代码」,而是 ESM 提供了静态结构,让工具有机会更可靠地做这件事。
re-export 是组织模块边界的工具
很多项目会有这样的入口文件:
// components/index.js
export { default as Button } from './Button.js'
export { default as Dialog } from './Dialog.js'
export { default as Input } from './Input.js'
这叫 re-export。它不会在当前模块里先 import 再手动 export 一个本地变量,而是把另一个模块的导出转发出去。
还有一种批量转发:
export * from './date.js'
export * from './format.js'
需要注意的是,export * 不会转发默认导出。如果要转发默认导出,需要显式写:
export { default as formatDate } from './format-date.js'
re-export 很适合用来做模块边界:外部只从一个入口导入,内部文件结构可以慢慢调整。
import { Button, Dialog } from '@/components'
但它也可能带来一个问题:入口文件变成「大桶」。如果桶文件聚合了很多带副作用的模块,或者让构建工具无法清楚判断副作用范围,就可能影响最终产物。实际项目里,桶文件最好保持简单,只做转发,不做初始化逻辑。
模块副作用决定它能不能被删除
看两个模块:
// pure.js
export function add(a, b) {
return a + b
}
// side-effect.js
console.log('loaded')
globalThis.__APP_VERSION__ = '1.0.0'
export function add(a, b) {
return a + b
}
如果 pure.js 的导出没有被使用,构建工具大概率可以删除它。但 side-effect.js 即使导出没被用,也不能轻易删除,因为执行这个模块会打印日志、修改全局对象。
这就是副作用对 tree shaking 的影响。
副作用不一定是坏事。样式注入、polyfill、注册自定义元素、初始化监控 SDK 都可能依赖模块副作用。问题在于:副作用应该尽量明显。
更清晰的写法通常是:
import { initAnalytics } from './analytics.js'
initAnalytics()
而不是:
import './analytics.js'
后者不是不能用,而是读者需要打开 analytics.js 才知道它到底做了什么。
在项目里怎么用好 ESM
理解机制之后,很多工程选择会变得更自然。
1. 让顶层代码尽量轻
模块顶层代码会在首次导入时执行一次。顶层适合放声明、常量、纯函数,不适合塞复杂初始化。
// 更容易测试和控制
export function createClient(options) {
return new Client(options)
}
比下面这种更可控:
// 一 import 就连接
export const client = new Client(loadOptions())
后者不是绝对错误,但它把「加载模块」和「启动资源」绑在了一起。测试、SSR、按需加载都会更难处理。
2. 避免顶层互相计算
循环依赖最危险的形态是顶层互相读值:
// a.js
import { b } from './b.js'
export const a = createA(b)
// b.js
import { a } from './a.js'
export const b = createB(a)
如果确实有双向关系,优先考虑把其中一边变成函数调用、注册过程,或者抽出第三个更底层的模块。
// shared.js
export const registry = new Map()
// a.js
import { registry } from './shared.js'
registry.set('a', createA)
// b.js
import { registry } from './shared.js'
registry.set('b', createB)
3. 用动态导入表达真正的边界
import() 适合用在运行时边界上:
- 路由级代码分割。
- 只在某个环境中加载的模块。
- 用户触发后才需要的重功能。
- 可选依赖。
async function openEditor() {
const { createEditor } = await import('./editor.js')
return createEditor()
}
这比把所有东西都静态导入进入口文件更诚实:这个模块确实不是启动时必须的。
4. 谨慎设计 index 文件
index.js 或 index.ts 很方便,但不要让它承担太多职责。
比较好的桶文件:
export { Button } from './Button.js'
export { Dialog } from './Dialog.js'
export { createTheme } from './theme.js'
比较危险的桶文件:
export { Button } from './Button.js'
export { Dialog } from './Dialog.js'
initTheme()
registerGlobalComponents()
startAnalytics()
当一个入口既负责导出,又负责初始化,它就不再只是模块边界,而变成了隐藏的启动脚本。
一个更稳定的心智模型
ESM 可以压缩成几句话:
- 静态
import描述模块图,不是普通的运行时代码。 export暴露的是绑定,import看到的是 live binding。- 模块先链接,再执行。
- 模块只执行一次,之后复用同一个模块实例。
- 循环依赖能不能工作,取决于初始化期间有没有读取未初始化的绑定。
- top-level await 会让依赖它的模块等待。
- Tree shaking 依赖静态结构,但最终还要看副作用。
理解这些之后,很多看似奇怪的行为就不再奇怪了。ESM 不是 CommonJS 的新皮肤,它的目标也不只是让语法更现代。它真正改变的是 JavaScript 程序的组织方式:从「运行时临时拼装依赖」变成「执行前先建立一张可分析的模块图」。