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 遷移是更符合技術趨勢的選擇。