ECMAScript Modules(ESM)
概述
ESM(ECMAScript Modules)是 JavaScript 的官方模块标准,由 TC39 在 ES6(ES2015)中正式引入。Node.js 从 v8.5.0 开始实验性支持 ESM,到 v12.11.1 稳定版落地,经历了漫长的过渡期。与 CJS 的同步阻塞加载不同,ESM 采用异步加载 + 静态分析的设计,这使 Tree-shaking、顶层 await、条件导出等现代特性成为可能。
ESM 加载链路详解
Node.js 中 ESM 的加载由 ESM Loader 负责,与 CJS 的 Module._load 是完全独立的代码路径:
与 CJS 加载链路的关键差异
| 维度 | CJS | ESM |
|---|---|---|
| 加载时机 | 运行时(require() 被调用时) | 解析时(静态分析 import 声明) |
| 加载方式 | 同步阻塞 | 异步非阻塞 |
| 解析标识 | 文件路径(相对/绝对/node_modules) | URL(file://、node:、data:) |
| 缓存机制 | require.cache(普通对象) | moduleMap(Map,按 URL 索引) |
| 循环依赖 | 返回部分导出 | 建立实时绑定(live binding) |
| 顶层 await | 不支持 | 支持 |
import 与 export 的静态分析
ESM 的 import/export 声明必须在模块顶层,且不能在运行时动态构造——这是静态分析的基础:
// ✅ 静态声明 — 可被静态分析
import { readFileSync } from 'node:fs';
export const version = '1.0.0';
// ❌ 动态导入路径 — 非法,ESM 不允许
// const mod = './' + name;
// import { foo } from mod;
// ✅ 动态场景使用 import()
const mod = await import('./' + name);实时绑定(Live Binding)
ESM 的 import 建立的是实时绑定,而非值的拷贝。当导出模块修改变量时,导入模块会感知到变化:
// counter.mjs
export let count = 0;
export function increment() {
count++;
}
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1 — 实时绑定,值已更新对比 CJS 中,require 返回的是 module.exports 的快照:
// counter.js (CJS)
let count = 0;
module.exports = { count, increment: () => count++ };
// count 是原始值,导出时已被拷贝
// main.js
const { count, increment } = require('./counter');
console.log(count); // 0
increment();
console.log(count); // 0 — 值拷贝,不会更新package.json 中的 ESM 配置
type 字段
package.json 的 type 字段决定了 .js 文件的模块系统解析规则:
{
"type": "module" // .js 文件按 ESM 解析
}{
"type": "commonjs" // .js 文件按 CJS 解析(默认值)
}| 文件扩展名 | type: "commonjs" | type: "module" |
|---|---|---|
.js | CJS | ESM |
.mjs | ESM | ESM |
.cjs | CJS | CJS |
.mjs和.cjs扩展名始终优先于type字段,是明确指定模块类型的硬性方式。
exports 字段 — 条件导出
exports 是 Node.js v12.7.0 引入的条件导出机制,允许同一个包根据不同的消费场景暴露不同的入口:
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/cjs/index.cjs"
},
"./feature": {
"import": "./dist/esm/feature.mjs",
"require": "./dist/cjs/feature.cjs"
},
"./package.json": "./package.json"
}
}条件匹配的优先级(从高到低):
node— Node.js 环境import— ESM 导入require— CJS requiredefault— 兜底条件
exports 的封装性
exports 字段一旦定义,包的所有导出必须显式声明。未在 exports 中声明的子路径将无法被外部访问:
{
"exports": {
".": "./lib/index.js"
}
}import pkg from 'my-lib'; // ✅ 命中 "."
import internal from 'my-lib/src/internal.js'; // ❌ ERR_PACKAGE_PATH_NOT_EXPORTED这与 CJS 的「包内所有文件皆可访问」形成鲜明对比——
exports实现了真正的公共 API 封装。
imports 字段 — 包内别名
imports(Node.js v12.19.0)为包内部提供模块别名,以 # 前缀标识:
{
"imports": {
"#internal": "./src/internal/index.js",
"#utils": "./src/utils/index.js"
}
}// 包内代码
import { helper } from '#internal';
import { format } from '#utils';
imports只能被包自身使用,外部包无法访问——与exports的封装逻辑一致。
双模式包(Dual Package)
在 CJS → ESM 的过渡期,库作者需要同时支持两种模块系统。双模式包是当前的主流方案:
方案一:条件导出(推荐)
{
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.mts",
"default": "./dist/esm/index.mjs"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
}
}
}方案二:ESM wrapper
CJS 作为主入口,ESM 入口仅做代理转发:
{
"type": "commonjs",
"main": "./dist/cjs/index.cjs",
"exports": {
".": {
"import": "./dist/esm/wrapper.mjs",
"require": "./dist/cjs/index.cjs"
}
}
}// dist/esm/wrapper.mjs
export * from '../cjs/index.cjs';
export { default } from '../cjs/index.cjs';双模式包的状态双份问题
⚠️ 核心陷阱:同一个包的 CJS 和 ESM 版本是两个不同的模块实例,它们各自维护独立的状态:
缓解策略:
- 避免在模块顶层维护可变状态
- CJS 和 ESM 版本共享同一个底层实现(wrapper 模式)
- 在
package.json中明确声明sideEffects: false
ESM Loader Hooks
Node.js v8.8.0 引入了 ESM 自定义加载器(Loader Hooks),允许开发者拦截和自定义模块加载的各个阶段:
// loader.mjs
export async function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('custom:')) {
return { url: specifier.replace('custom:', 'file://'), format: 'module' };
}
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
if (url.endsWith('.txt')) {
const content = await fs.readFile(new URL(url), 'utf-8');
return { format: 'module', source: `export default ${JSON.stringify(content)}` };
}
return nextLoad(url, context);
}node --experimental-loader ./loader.mjs app.mjs顶层 await
ESM 原生支持顶层 await,这使得模块初始化阶段的异步操作无需包裹在 async IIFE 中:
// config.mjs — 顶层 await
const response = await fetch('https://api.example.com/config');
const config = await response.json();
export default config;
// app.mjs
import config from './config.mjs';
console.log(config); // 配置已加载完成注意:顶层 await 会阻塞依赖它的所有模块,可能导致整个依赖图的执行被延迟。在性能敏感场景需谨慎使用。
ESM 与 CJS 的互操作矩阵
| 消费方 | 被消费方 | 方式 | 说明 |
|---|---|---|---|
| ESM | CJS | import cjs from './cjs.cjs' | CJS 的 module.exports 成为 ESM 的 default 导出 |
| ESM | CJS 命名导出 | import { foo } from './cjs.cjs' | ⚠️ 通过静态分析启发式检测,不可靠 |
| CJS | ESM | await import('./esm.mjs') | 必须使用动态 import(),require 不支持 |
| CJS | ESM 同步 | 不可能 | ESM 的异步加载模型与 CJS 的同步 require 不兼容 |
命名导出的启发式检测
当 ESM 导入 CJS 模块时,Node.js 会尝试通过启发式分析将 module.exports 的属性提升为命名导出:
// cjs-module.cjs
module.exports = { foo: 1, bar: 2 };
// esm-consumer.mjs
import { foo, bar } from './cjs-module.cjs'; // ✅ 大概率可以但这种检测不可靠——如果 module.exports 在运行时动态构造,静态分析将失败:
// cjs-dynamic.cjs
const key = process.env.KEY;
module.exports = { [key]: 'value' };
// esm-consumer.mjs
import { value } from './cjs-dynamic.cjs'; // ❌ 可能失败最佳实践:从 CJS 模块导入时,始终使用 default 导出:
import cjsModule from './cjs-module.cjs';
const { foo, bar } = cjsModule;