Node系列 · Node基础:ES 模块化
CommonJS 是 Node 默认的模块系统,但 ESM 才是 ECMAScript 规范本身。理解 ESM 的"异步加载 + 静态分析"特性,就能解释为什么它能 tree-shaking、为什么必须写文件后缀、为什么与 CJS 互操作时要写
default解构。
一、ESM 与 CommonJS 的关键差异
| 维度 | CommonJS | ESM |
|---|---|---|
| 规范归属 | Node 自定义实现 | ECMAScript 标准 |
| 加载方式 | 同步、运行时 | 异步、静态分析 |
| 关键字 | require / module.exports | import / export |
| 文件后缀 | 自动补全 | 必须显式写 |
| Tree-shaking | 困难(运行时才知道导出什么) | 天然支持 |
顶层 await | 不支持 | 支持(Node 14.8+) |
| 适用 | 老项目、Node CLI、配置文件 | 现代前端、库发布、tree-shaking 场景 |
INFO
ESM 在 Node 14+ 已经很稳定。新项目默认 ESM;维护老 CJS 项目不必迁移,除非需要 tree-shaking 或与 .mjs 包互操作。
二、启用 ESM 的两种方式
2.1 用 .mjs 后缀
文件后缀 .mjs 强制按 ESM 解析,与 package.json 配置无关:
project/
├── package.json
└── app.mjsimport { readFile } from 'node:fs/promises';
const data = await readFile('./config.json', 'utf-8');2.2 在 package.json 加 "type": "module"
整个项目(除 .cjs 文件)按 ESM 解析:
{
"name": "my-app",
"version": "1.0.0",
"type": "module"
}project/
├── package.json
└── src/
├── index.js ← 现在按 ESM 解析
└── util.cjs ← 显式按 CJS 解析(即便在 type=module 项目下)TIP
混用场景:项目主入口是 ESM,但某个老依赖只能以 CJS 形式发布——把那个文件改成 .cjs 后缀即可。
三、import 语法
3.1 命名导入 / 默认导入
// 命名导出:可以有多个
export const PI = 3.14;
export function add(a, b) { return a + b; }
// 默认导出:一个模块只能有一个
export default class User {
constructor(name) { this.name = name; }
}// 命名导入:必须用花括号
import { PI, add } from './export-demo.js';
// 默认导入:花括号外,可以任意命名
import User from './export-demo.js';
// 混合导入
import User, { PI, add } from './export-demo.js';
// 重命名导入
import { add as sum } from './export-demo.js';
// 整体导入为一个命名空间对象
import * as utils from './export-demo.js';
console.log(utils.PI); // 3.143.2 路径规则
ESM 下 import 的路径有 3 个强约束:
| 写法 | 是否合法 | 说明 |
|---|---|---|
import x from './foo.js' | ✅ | 必须带 .js 后缀 |
import x from './foo' | ❌ | 必须显式后缀(CJS 会自动补全,ESM 不会) |
import x from 'foo' | ⚠️ | 走 npm 包解析(同 CJS 的 node_modules 查找) |
import x from 'node:fs' | ✅ | Node 内置模块用 node: 前缀更规范 |
WARNING
ESM 不补全后缀。老 CJS 项目里到处是 require('./foo'),迁到 ESM 后必须改成 import x from './foo.js'。否则运行时报 ERR_MODULE_NOT_FOUND。
四、export 语法
4.1 命名导出 vs 默认导出
// 命名导出:导入时必须用同名
export const name = 'Alice';
export function greet() {}
// 默认导出:导入时任意命名
export default function () {
return 'default function';
}4.2 重导出(聚合模块)
barrel 文件(一个文件聚合多个子模块的导出):
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Select } from './Select.js';使用方只要 import { Button } from './components/index.js' 即可。
4.3 重新导出并重命名
export { foo as bar } from './source.js'; // 导出 source 的 foo,但消费方叫 bar五、ESM 互操作
实际项目里经常要 CJS 和 ESM 混用,两种场景的互操作语法不一样。
5.1 在 ESM 中 import CJS 模块
CJS 模块的 module.exports 整体被 ESM 当成默认导出:
// 一个普通 CJS 模块
module.exports = {
hello: () => 'world',
PI: 3.14,
};// 在 ESM 里引用 CJS
import cjs from './cjs-module.js';
console.log(cjs.hello()); // 'world'
console.log(cjs.PI); // 3.14如果 CJS 用 module.exports.something = ... 拆成多个具名导出,ESM 也能通过 import { something } 解构:
exports.foo = 1;
exports.bar = 2;import { foo, bar } from './cjs-named.js';WARNING
Node 不做 CJS 的静态分析,import { something } 引用一个 CJS 模块时,实际是运行后从 module.exports 解构。如果 CJS 用了动态赋值(比如 if (cond) exports.x = ...),ESM 拿不到。
5.2 在 CJS 中 require ESM 模块
不允许——CJS 是同步加载,ESM 是异步加载。Node 提供了两种方式绕过:
动态 import() 表达式
import() 不是声明,是表达式,返回 Promise:
async function load() {
const { add } = await import('./esm-module.mjs');
console.log(add(1, 2)); // 3
}
load();createRequire 构造一个 CJS 风格的 require
只用于加载 CJS 模块,不能 require 一个 ESM。
5.3 互操作矩阵
| 调用方 \ 被调用方 | CJS 模块 | ESM 模块 |
|---|---|---|
| CJS 模块 | require() ✅ | ❌ 用动态 await import() |
| ESM 模块 | import default from '...' ✅ | import { ... } from '...' ✅ |
六、顶层 await
ESM 模块顶层允许直接 await——这是 CJS 完全没有的能力:
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);限制与注意点:
- 必须用在 ESM 模块(
.mjs或package.jsontype=module) - 模块的"加载完成"变成异步——所有依赖它的模块都必须等待
- 不要在顶层
await不会立即 resolve 的 Promise,否则所有 import 它的模块都会被卡住
// ❌ 危险:长时间阻塞
await new Promise((resolve) => setTimeout(resolve, 60_000));
console.log('所有人都得等我 60 秒');TIP
顶层 await 的最佳场景:
- 读配置文件作为模块初始化的依据
- 一次性预热缓存 / 拉取启动数据
- 单实例服务启动前的健康检查
不适合:
- 长任务(用户请求、消息队列消费)
- 不确定的资源获取
七、ESM 的加载流程
ESM 的"异步、静态分析"体现在加载流程:
CJS 是同步串行:require('./a.js') 一进来就读文件、执行完才返回。ESM 是并行预加载:所有依赖文件并行读,最后按依赖图顺序执行。
八、ESM 与 CJS 的选择建议
| 场景 | 推荐 | 理由 |
|---|---|---|
| 新建 Node 项目 | ESM | 规范方向、生态趋势、tree-shaking |
| 写一个发到 npm 的库 | ESM(同时支持 CJS via dual package) | 下游用户两种生态都有 |
| 维护老 CJS 项目 | 继续 CJS | 迁移成本高,收益有限 |
| CLI 工具 | CJS / ESM 都行 | 单文件执行,无依赖 |
必须用同步 require | CJS | ESM 不支持同步加载 |
必须用 __dirname / __filename | CJS(或 ESM 下用 import.meta.url 转换) | 见下一节 |
九、ESM 下的 __dirname 等价物
ESM 没有 __dirname / __filename,但能用 import.meta 拿到当前模块的 URL:
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);import.meta 携带了当前模块的元信息:
| 属性 | 含义 |
|---|---|
import.meta.url | 当前模块的 file:// URL |
import.meta.dirname | 当前模块目录的路径(Node 21.2+) |
import.meta.filename | 当前模块文件的路径(Node 21.2+) |
import.meta.resolve(specifier) | 解析一个 specifier 为 URL(Node 20.6+) |
Node 21.2+ 直接提供了 import.meta.dirname 和 import.meta.filename,不需要再 fileURLToPath。
十、常见错误
| 错误信息 | 原因 | 解决 |
|---|---|---|
ERR_MODULE_NOT_FOUND | 路径缺后缀或拼错 | 写完整 ./foo.js;检查文件名 |
The requested module './foo' does not provide an export named 'X' | CJS 模块没 module.exports.X | 改成 import foo from './foo' 默认导入 |
await is only valid in async functions | 顶层 await 用在 CJS | 改 .mjs 或加 "type": "module" |
Cannot use import statement outside a module | CJS 文件里写了 import | 改 .mjs 后缀,或用 require |
require() of ES Module ... not supported | CJS 里同步 require ESM | 改用 await import() |
十一、小结
- ESM 是 ECMAScript 标准;CJS 是 Node 自定义实现。新项目默认 ESM
- 启用 ESM 两种方式:
.mjs后缀 /package.json type=module - ESM 必须写文件后缀(
./foo.js);CJS 不会自动补全——这是迁移最常见的报错 - 互操作:ESM
importCJS ✅;CJSrequireESM ❌(用动态import()) - 顶层
await是 ESM 独有,但只用于"启动期一次性"任务 - Node 21.2+ 提供
import.meta.dirname/import.meta.filename,简化 ESM 下的路径处理
