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 配置无关:
text
project/
├── package.json
└── app.mjs
javascript:app.mjs
import { readFile } from 'node:fs/promises';
const data = await readFile('./config.json', 'utf-8');
2.2 在 package.json 加 "type": "module"
整个项目(除 .cjs 文件)按 ESM 解析:
json:package.json
{
"name": "my-app",
"version": "1.0.0",
"type": "module"
}
text
project/
├── package.json
└── src/
├── index.js ← 现在按 ESM 解析
└── util.cjs ← 显式按 CJS 解析(即便在 type=module 项目下)
::: tip
混用场景:项目主入口是 ESM,但某个老依赖只能以 CJS 形式发布------把那个文件改成 .cjs 后缀即可。
:::
三、import 语法
3.1 命名导入 / 默认导入
javascript:export-demo.js
// 命名导出:可以有多个
export const PI = 3.14;
export function add(a, b) { return a + b; }
// 默认导出:一个模块只能有一个
export default class User {
constructor(name) { this.name = name; }
}
javascript:import-demo.js
// 命名导入:必须用花括号
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.14
3.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 默认导出
javascript:export-types.js
// 命名导出:导入时必须用同名
export const name = 'Alice';
export function greet() {}
// 默认导出:导入时任意命名
export default function () {
return 'default function';
}
4.2 重导出(聚合模块)
barrel 文件(一个文件聚合多个子模块的导出):
javascript:components/index.js
export { Button } from './Button.js';
export { Input } from './Input.js';
export { Select } from './Select.js';
使用方只要 import { Button } from './components/index.js' 即可。
4.3 重新导出并重命名
javascript:export-rename.js
export { foo as bar } from './source.js'; // 导出 source 的 foo,但消费方叫 bar
五、ESM 互操作
实际项目里经常要 CJS 和 ESM 混用,两种场景的互操作语法不一样。
5.1 在 ESM 中 import CJS 模块
CJS 模块的 module.exports 整体被 ESM 当成默认导出:
javascript:cjs-module.js
// 一个普通 CJS 模块
module.exports = {
hello: () => 'world',
PI: 3.14,
};
javascript:import-cjs.mjs
// 在 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 } 解构:
javascript:cjs-named.js
exports.foo = 1;
exports.bar = 2;
javascript:import-cjs-named.mjs
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:
javascript:require-esm.cjs
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 完全没有的能力:
javascript:top-level-await.mjs
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 它的模块都会被卡住
javascript:top-level-await-bad.mjs
// ❌ 危险:长时间阻塞
await new Promise((resolve) => setTimeout(resolve, 60_000));
console.log('所有人都得等我 60 秒');
::: tip
顶层 await 的最佳场景:
- 读配置文件作为模块初始化的依据
- 一次性预热缓存 / 拉取启动数据
- 单实例服务启动前的健康检查
不适合:
- 长任务(用户请求、消息队列消费)
- 不确定的资源获取
:::
七、ESM 的加载流程
ESM 的"异步、静态分析"体现在加载流程:
文件系统 Node ESM Loader 入口 .mjs 文件系统 Node ESM Loader 入口 .mjs #mermaid-svg-gHZm9DQMdLTi8oJ6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .error-icon{fill:#552222;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .marker.cross{stroke:#333333;}#mermaid-svg-gHZm9DQMdLTi8oJ6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gHZm9DQMdLTi8oJ6 p{margin:0;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gHZm9DQMdLTi8oJ6 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gHZm9DQMdLTi8oJ6 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-gHZm9DQMdLTi8oJ6 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .sequenceNumber{fill:white;}#mermaid-svg-gHZm9DQMdLTi8oJ6 #sequencenumber{fill:#333;}#mermaid-svg-gHZm9DQMdLTi8oJ6 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .messageText{fill:#333;stroke:none;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .labelText,#mermaid-svg-gHZm9DQMdLTi8oJ6 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .loopText,#mermaid-svg-gHZm9DQMdLTi8oJ6 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-gHZm9DQMdLTi8oJ6 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .noteText,#mermaid-svg-gHZm9DQMdLTi8oJ6 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .actorPopupMenu{position:absolute;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-gHZm9DQMdLTi8oJ6 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-gHZm9DQMdLTi8oJ6 .actor-man circle,#mermaid-svg-gHZm9DQMdLTi8oJ6 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-gHZm9DQMdLTi8oJ6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 所有依赖加载完成后 才执行任意模块 import './a.js' 静态分析入口文件 找出所有 import 语句 并行读取 ./a.js / ./b.js / ./c.js 文件内容 构建依赖图 (拓扑排序) 执行入口文件
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:
javascript:esm-dirname.mjs
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 下的路径处理