Node.js 模块系统:CommonJS 和 ES Modules 核心差异与实战指南
一、模块系统基础概念
**CommonJS (CJS)** 是 Node.js 传统模块系统,采用同步加载方式,典型特征:
// 导出
module.exports = { name: 'cjs' }; // 或 exports.name = 'cjs'// 导入
const moduleA = require('./moduleA'); // 动态语法
**ES Modules (ESM)** 是 ECMAScript 标准模块系统,采用异步加载,典型特征:
// 导出
export const name = 'esm'; // 命名导出
export default { version: 1 }; // 默认导出// 导入
import moduleB, { name } from './moduleB.mjs'; // 静态语法
二、7 个关键差异点(附代码验证)
1. 语法与加载机制
- CJS 动态加载:允许条件语句中 require
if (Math.random() > 0.5) {require('./moduleA'); // 运行时决定加载
}
- ESM 静态分析:import 必须顶层声明
// 报错:import 必须位于模块顶部
if (condition) { import './moduleB.mjs' }
2. 模块作用域差异
- CJS 非严格模式:变量可隐式创建全局变量
// module-cjs.js
undeclaredVar = 100; // 不报错,污染全局
- ESM 严格模式:禁止隐式全局变量
// module-esm.mjs
undeclaredVar = 100; // 报错:未定义变量
3. 循环引用处理
- CJS 动态引用:可能拿到未初始化的模块
// a.js
exports.loaded = false;
const b = require('./b');
console.log('在a中,b.loaded =', b.loaded); // true
exports.loaded = true;// b.js
exports.loaded = false;
const a = require('./a');
console.log('在b中,a.loaded =', a.loaded); // false
exports.loaded = true;// 执行 node a.js → 输出顺序:
// 在b中,a.loaded = false
// 在a中,b.loaded = true
- ESM 静态绑定:引用指向最新值(类似指针)
// a.mjs
import { loaded } from './b.mjs';
export let loaded = false;
console.log('在a中,b.loaded =', loaded); // true
loaded = true;// b.mjs
import { loaded } from './a.mjs';
export let loaded = false;
console.log('在b中,a.loaded =', loaded); // false
loaded = true;// 执行 node a.mjs → 报错(循环引用需特殊处理)
4. 顶层 this 指向
- CJS 的 this 指向
module.exports
对象
console.log(this === module.exports); // true
- ESM 的 this 为
undefined
(严格模式)
console.log(this); // undefined
5. 文件扩展名与配置
- CJS 默认识别
.js
和.cjs
文件 - ESM 需要以下条件之一:
- 文件后缀为
.mjs
- 最近的
package.json
中设置"type": "module"
- 文件后缀为
// package.json
{"type": "module" // 项目内 .js 文件默认视为 ESM
}
6. 引用类型差异
- CJS 导出值拷贝:基本类型值复制,对象类型浅拷贝
// cjs-module.js
let count = 1;
setTimeout(() => { count = 2 }, 100);
module.exports = { count };// main.js
const { count } = require('./cjs-module');
console.log(count); // 1
setTimeout(() => console.log(count), 200); // 仍为1
- ESM 动态绑定:始终获取最新值
// esm-module.mjs
export let count = 1;
setTimeout(() => { count = 2 }, 100);// main.mjs
import { count } from './esm-module.mjs';
console.log(count); // 1
setTimeout(() => console.log(count), 200); // 变为2
7. 动态导入能力
- CJS 原生不支持动态导入,但可通过
require
实现 - ESM 支持
import()
动态导入(返回 Promise)
// 动态加载 ESM 模块
const module = await import('./module.mjs');// 动态加载 CJS 模块(在 ESM 中)
import cjsModule from './cjs-module.cjs'; // 需完整后缀
三、日常开发建议
1. 新项目技术选型
- 优先使用 ESM:符合语言标准,支持 Tree Shaking
// package.json
{"type": "module","scripts": {"start": "node --experimental-vm-modules src/index.mjs"}
}
2. 旧项目迁移策略
- 渐进式迁移:
- 将单个文件后缀改为
.mjs
或设置"type": "module"
- 使用
import/export
语法逐步替换
- 将单个文件后缀改为
// 混合使用示例(在 ESM 中引入 CJS)
import cjsModule from './legacy-module.cjs'; // 注意后缀
3. 模块兼容性处理
- 双格式发布库:通过
package.json
指定双入口
{"exports": {"import": "./esm-module.mjs","require": "./cjs-module.cjs"}
}
4. 避免踩坑指南
- 禁用默认互操作:CJS 默认导出需特别注意
// ESM 导入 CJS 模块
import cjsModule from './cjs-module.cjs'; // module.exports 整体作为默认导出
- 循环引用处理:ESM 中建议使用函数封装初始化逻辑
// a.mjs
import { initB } from './b.mjs';
export let valueA = '未初始化';export function initA() {valueA = '初始化A';initB();
}// b.mjs
import { initA } from './a.mjs';
export let valueB = '未初始化';export function initB() {valueB = '初始化B';initA(); // 安全调用
}
四、注意事项
-
全局变量替换:
ESM 中无法直接使用__dirname
,需改用:import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url));
-
文件扩展名强制要求:
在 ESM 中引入文件时必须写完整扩展名:import './utils.js'; // 必须写 .js
-
默认导出差异:
CJS 的module.exports
对应 ESM 的默认导出:// CJS 模块 module.exports = { a: 1 };// ESM 导入方式 import cjsModule from './cjs-module.cjs'; // { a: 1 }
-
性能优化:
ESM 的静态分析特性使打包工具(如 Rollup)能实现更高效的 Tree Shaking。
五、总结
理解两种模块系统的核心差异,能帮助开发者根据场景合理选择:
- CJS 适合传统 Node.js 项目、需要动态加载的场景
- ESM 适合现代浏览器兼容项目、需要静态分析的构建优化
在混合项目中,通过文件扩展名和 package.json
配置明确模块类型,避免隐式错误。对于长期维护的项目,逐步向 ESM 迁移是更符合技术趋势的选择。