ESM(ECMAScript Modules)是 JavaScript 的官方模块标准,由 TC39 在 ES6(ES2015)中正式引入。Node.js 从 v8.5.0 开始实验性支持 ESM,v12.17.0 起使用 ESM 不再需要标志、也不再打印实验性警告(官方文档将 ESM 稳定标记追溯至 v12.22.0),经历了漫长的过渡期。与 CJS 的同步阻塞加载不同,ESM 采用异步加载 + 静态分析的设计,这使 Tree-shaking、顶层 await、条件导出等现代特性成为可能。
Node.js 中 ESM 的加载由 ESM Loader 负责,与 CJS 的 Module._load 是完全独立的代码路径:
| 维度 | CJS | ESM |
|---|---|---|
| 加载时机 | 运行时(require() 被调用时) | 解析时(静态分析 import 声明) |
| 加载方式 | 同步阻塞 | 异步非阻塞 |
| 解析标识 | 文件路径(相对/绝对/node_modules) | URL(file://、node:、data:) |
| 缓存机制 | require.cache(普通对象) | moduleMap(Map,按 URL 索引) |
| 循环依赖 | 返回部分导出 | 建立实时绑定(live binding) |
| 顶层 await | 不支持 | 支持 |
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);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 的 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 是 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"
}
}常见条件:
import — ESM 导入require — CJS requirenode — Node.js 环境(不限模块系统)default — 兜底条件条件按
exports对象中的声明顺序从上到下匹配,先命中先用——因此惯例是把更具体的条件(如import/require)写在default之前。
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(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的封装逻辑一致。
在 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"
}
}
}
}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 版本是两个不同的模块实例,它们各自维护独立的状态:
缓解策略:
package.json 中明确声明 sideEffects: falseNode.js v8.8.0 引入了 ESM 自定义加载器(Loader Hooks),允许开发者拦截和自定义模块加载的各个阶段:
// loader.mjs
import fs from 'node:fs';
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.mjsESM 原生支持顶层 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 | import cjs from './cjs.cjs' | CJS 的 module.exports 成为 ESM 的 default 导出 |
| ESM | CJS 命名导出 | import { foo } from './cjs.cjs' | ⚠️ 通过静态分析启发式检测,不可靠 |
| CJS | ESM | await import('./esm.mjs') | 动态 import() 在所有版本可用 |
| CJS | ESM 同步 | const pkg = require('./esm.mjs') | Node 22.12+ 支持加载不含顶层 await 的 ESM(require(esm));含顶层 await 的仍须 await import() |
当 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;