Skip to content

Node系列 · Node基础:ES 模块化

CommonJS 是 Node 默认的模块系统,但 ESM 才是 ECMAScript 规范本身。理解 ESM 的"异步加载 + 静态分析"特性,就能解释为什么它能 tree-shaking、为什么必须写文件后缀、为什么与 CJS 互操作时要写 default 解构。

一、ESM 与 CommonJS 的关键差异

维度CommonJSESM
规范归属Node 自定义实现ECMAScript 标准
加载方式同步、运行时异步、静态分析
关键字require / module.exportsimport / 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
import { readFile } from 'node:fs/promises';
const data = await readFile('./config.json', 'utf-8');

2.2 在 package.json"type": "module"

整个项目(除 .cjs 文件)按 ESM 解析:

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 const PI = 3.14;
export function add(a, b) { return a + b; }

// 默认导出:一个模块只能有一个
export default class User {
  constructor(name) { this.name = name; }
}
javascript
// 命名导入:必须用花括号
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 const name = 'Alice';
export function greet() {}

// 默认导出:导入时任意命名
export default function () {
  return 'default function';
}

4.2 重导出(聚合模块)

barrel 文件(一个文件聚合多个子模块的导出):

javascript
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 { foo as bar } from './source.js'; // 导出 source 的 foo,但消费方叫 bar

五、ESM 互操作

实际项目里经常要 CJS 和 ESM 混用,两种场景的互操作语法不一样。

5.1 在 ESM 中 import CJS 模块

CJS 模块的 module.exports 整体被 ESM 当成默认导出

javascript
// 一个普通 CJS 模块
module.exports = {
  hello: () => 'world',
  PI: 3.14,
};
javascript
// 在 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
exports.foo = 1;
exports.bar = 2;
javascript
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
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
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);

限制与注意点:

  • 必须用在 ESM 模块.mjspackage.json type=module)
  • 模块的"加载完成"变成异步——所有依赖它的模块都必须等待
  • 不要在顶层 await 不会立即 resolve 的 Promise,否则所有 import 它的模块都会被卡住
javascript
// ❌ 危险:长时间阻塞
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 都行单文件执行,无依赖
必须用同步 requireCJSESM 不支持同步加载
必须用 __dirname / __filenameCJS(或 ESM 下用 import.meta.url 转换)见下一节

九、ESM 下的 __dirname 等价物

ESM 没有 __dirname / __filename,但能用 import.meta 拿到当前模块的 URL:

javascript
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.dirnameimport.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 moduleCJS 文件里写了 import.mjs 后缀,或用 require
require() of ES Module ... not supportedCJS 里同步 require ESM改用 await import()

十一、小结

  • ESM 是 ECMAScript 标准;CJS 是 Node 自定义实现。新项目默认 ESM
  • 启用 ESM 两种方式:.mjs 后缀 / package.json type=module
  • ESM 必须写文件后缀(./foo.js);CJS 不会自动补全——这是迁移最常见的报错
  • 互操作:ESM import CJS ✅;CJS require ESM ❌(用动态 import()
  • 顶层 await 是 ESM 独有,但只用于"启动期一次性"任务
  • Node 21.2+ 提供 import.meta.dirname / import.meta.filename,简化 ESM 下的路径处理