随着 ECMAScript 2015(ES6)正式将模块系统纳入语言标准,前端世界早已习惯 import 和 export 的语法。Node.js 在很长一段时间内坚持使用 CommonJS 模块系统,但自 v12.17.0 起开始稳定支持 ES Modules,v14 之后已经完全可投入生产。如今,ESM 已经成为 Node.js 项目的标准模块方案之一,了解其机制对于现代 Node.js 开发不可或缺。
5.3.1 ESM 与 CommonJS 的核心差异
ES Modules 和 CommonJS 并不是简单的语法差异,它们在加载机制、执行逻辑、模块导出方式和缓存策略上都有本质区别。
| 特性 | CommonJS | ES Modules |
|------|----------|------------|
| 语法 | require() / module.exports | import / export |
| 加载时机 | 运行时,动态加载 | 编译时,静态分析 |
| 执行顺序 | 同步加载,执行完再返回 | 异步加载,先解析所有模块再按依赖顺序执行 |
| 导出绑定 | 导出的是值的拷贝(对象) | 导出的是值的实时绑定(引用) |
| 默认模式 | 严格模式非必须 | 自动启用严格模式 |
| 顶层 await | 不支持 | 支持 |
| 树摇优化 | 不易实现 | 对打包工具友好,可消除未用代码 |
实时绑定与拷贝的差异,是最容易在实践中踩坑的地方。在 CommonJS 中,一旦模块被加载执行,module.exports 就是一个普通对象。外部获取到的只是该对象的引用(拷贝了导出值),如果模块内部后续修改了原变量,外部拿到的还是旧值。
而 ESM 的导出是绑定的引用,导出的变量与模块内部变量保持动态关联。模块实例始终指向同一块内存,只要内部值变化,外部读取时也会得到新值。看个例子:
// counter.mjs (ESM)
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 (实时绑定)
如果换成 CommonJS:
// counter.cjs
let count = 0;
module.exports = {
count,
increment() { count++; }
};
// main.cjs
const counter = require('./counter.cjs');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 仍然 0,因为 count 是 primitive 的拷贝
因此,在需要导出可变的计数器、状态标记等场景时,ESM 的行为更符合直觉。
5.3.2 启用方式与环境识别
Node.js 通过文件的扩展名和 package.json 来决定一个文件是作为 ES Module 还是 CommonJS 模块加载。目前有三种主流方式:
1. .mjs 扩展名
任何以 .mjs 结尾的文件,Node.js 会强制将其识别为 ES Module。同样,.cjs 后缀强制识别为 CommonJS。这种显式声明最可靠,推荐在混合模块的项目中使用。
// math.mjs
export function add(a, b) { return a + b; }
2. package.json 中的 "type": "module"
在 package.json 中设置 "type": "module",则该包下的所有 .js 文件都会被当作 ES Module。如果需要某些文件保持 CommonJS,可以将其命名为 .cjs。
{
"name": "my-app",
"type": "module"
}
设置后,node index.js 中的 index.js 就可以使用 import 语法。如果未设置 "type" 或设置为 "commonjs",则 .js 默认是 CommonJS 模块。
3. 动态 import()
import() 函数动态加载模块,返回 Promise,可以在 CommonJS 模块中使用。它是实现 ESM/CommonJS 交叉加载最常见的手段。
// 在 CJS 文件中
const { default: _ } = (async () => {
return await import('lodash');
})();
动态 import() 也是 ESM 规范的一部分,它不会受到宿主模块类型的限制,灵活性极高。
5.3.3 互操作规则:ESM 如何加载 CJS,反之亦然
在实际项目中,模块系统混用是常见状态。Node.js 对互操作提供了一定支持,但限制同样明显。
ESM 加载 CommonJS 模块
ESM 模块可以使用 import 直接导入一个 CommonJS 模块。Node.js 会将 CJS 的 module.exports 作为默认导出。例如:
// common-stuff.cjs
module.exports = { foo: 'bar' };
// consumer.mjs
import stuff from './common-stuff.cjs';
console.log(stuff.foo); // 'bar'
也可以使用命名导入,但 仅限于 CJS 模块在 module.exports 上直接导出的属性,不能导入深层嵌套属性。并且,CJS 模块的动态特性(如依赖注入,循环引用)可能导致导入结果分析与预期不符,应当谨慎使用。
CommonJS 加载 ES Module
CommonJS 中不能直接使用 require() 加载 ES Module。require() 是同步的,而 ESM 模块的加载和解析是异步的(因为可能包含顶层 await),这一冲突导致 require 加载 .mjs 文件时会直接抛出错误。
// 错误示例
const math = require('./math.mjs'); // Error [ERR_REQUIRE_ESM]
正确的做法是使用动态 import():
// loader.cjs
(async () => {
const math = await import('./math.mjs');
console.log(math.add(1, 2));
})();
或者将 CommonJS 模块本身迁移为 ES Module,避免逆向加载。
命名导出与默认导出的转换
CJS 模块的 module.exports 是一个值(可以是函数、对象、原始类型),它是 ESM 侧的默认导出。如果 CJS 模块习惯用 module.exports = function() {},那么在 ESM 中 import fn from './module' 即可。若想获得类似命名导出的效果,只能通过静态分析 module.exports 对象的属性(Node.js 会尝试生成命名导出),但这对动态赋值无效,最佳实践是统一模块风格,避免混用时的意外。
5.3.4 加载时机与执行顺序
ESM 的静态特性决定了模块依赖关系在代码执行前就可以确定。Node.js 会先解析所有 import 声明,构建模块依赖图,再按照依赖顺序从叶子节点开始执行模块代码。这种设计带来几个重要的工程优势:
- 循环依赖处理更安全:CommonJS 中循环引用可能导致导出对象尚未准备好而读到空值;ESM 因为先解析后执行,并导出实时绑定,循环引用时只要不在顶层立即使用对方的值,通常不会出错。
- 支持顶层
await:模块可以像async函数一样在顶层使用await,这让初始化数据库连接、读取配置文件等操作变得简洁优雅。但顶层await会阻塞依赖它的模块的执行,直到该模块完成,因此使用时要避免造成不必要的加载延迟。
// db.mjs
import mysql from 'mysql2/promise';
export const connection = await mysql.createConnection({...});
// app.mjs
import { connection } from './db.mjs'; // 会自动等待 db.mjs 初始化完毕
- 静态分析利于工具优化:打包工具(Webpack、Rollup)和 Node.js 本身都能基于静态导入做 Tree Shaking,自动去除未使用的导出代码,减小最终体积。
5.3.5 需要注意的细节与实战建议
- 入口文件扩展名不能省略
CommonJS 中可以省略 .js 或 .json,但在 ESM 中必须显式写全扩展名,如 import foo from './foo.js'。Node.js 不会自动补全,这是与 CommonJS 的一大使用差异。
dirname和filename 不复存在
在 ES Module 中,这些 CommonJS 全局变量不可用。取而代之,可以通过 import.meta.url 获取当前模块的 URL,然后配合 url.fileURLToPath 转回文件路径。
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
require常用功能已提供替代方案
动态 import() 替代 require 动态加载;module.createRequire 可以在 ESM 中创建一个 require 函数,用于加载 JSON 或 CJS 模块。
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('./package.json');
- 关于
.js扩展名的争议
Node.js 文档明确要求 ESM 导入路径必须带扩展名,但 TypeScript 编译和某些打包工具允许省略。如果项目同时涉及 Node.js 原生运行和构建流程,最好统一使用完整扩展名,或依赖构建工具解析,否则在不同环境下可能出现不一致。
- 生产环境选择
目前多数后端框架(Express、Koa、Fastify)的示例和插件仍以 CommonJS 为主,NestJS 等现代化框架则已全面拥抱 ESM。新项目建议优先采用 ESM(设置 "type": "module"),并处理好与老库的兼容。对于依赖大量 CommonJS 第三方包的老项目,强行迁移可能带来大量互操作问题,可保持现状,逐步过渡。
- 与 TypeScript 的协作
如果使用 TypeScript,可以在 tsconfig.json 中配置 "module": "node16" 或 "module": "ESNext",TypeScript 会生成对应的 ESM 输出,并处理扩展名。推荐搭配 "moduleResolution": "node16",以符合 Node.js 的 ESM 解析规则。
5.3.6 小结
ES Modules 是 JavaScript 标准的模块系统,Node.js 对其的支持已经非常成熟。相比 CommonJS,它提供了编译时导入、实时绑定、顶层 await 等现代化特性,更适合大型项目和全栈 TypeScript 开发。但是互操作性仍需开发者多加留意,尤其是在混合使用两种模块系统时,遵循“能统一则统一,混用时用 import() 桥接”的原则,可以有效减少模块加载问题。理解 ESM 的运行机制,能帮助我们在编写模块化代码时避开许多隐藏的陷阱,也为后续微服务、Serverless 等场景的模块拆分打下坚实基础。