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

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 模块.mjspackage.json type=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.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 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 import CJS ✅;CJS require ESM ❌(用动态 import()
  • 顶层 await 是 ESM 独有,但只用于"启动期一次性"任务
  • Node 21.2+ 提供 import.meta.dirname / import.meta.filename,简化 ESM 下的路径处理
相关推荐
深念Y2 小时前
Opencode Event 表写入优化方案
数据库·人工智能·ai·node.js·bug·优化·opencode
ikun778g3 小时前
DeepSeek Harness 本地部署保姆级教程:从 Node.js 24.0.0 安装到 WorkBuddy 一键运行
ai·node.js
烂蜻蜓17 小时前
Node.js入门教程(二十三):全局对象
node.js·编辑器·vim
xywww1681 天前
Node.js Claude API 实战接入:SDK 调用 Opus 5、环境变量配置与报错排查
node.js
weixin_431600441 天前
NestJS 入门(7):生命周期钩子——构造函数和 `OnModuleInit` 差在哪?
前端·后端·学习·node.js·nest.js
深念Y1 天前
基于 NapCat 与本地 RAG 的群聊 AI 机器人方案(ARM64 部署)
人工智能·ai·机器人·node.js·自动化·情感陪伴·bot
__zRainy__1 天前
Node系列 · Node基础:Node.js 概述
后端·node.js
烂蜻蜓1 天前
Node.js入门教程(二十四):常用工具(util 模块)
node.js
oushaojun21 天前
使用docker安装node.js和deepseek harness
node.js·dsh