Node.js 模块系统:CommonJS 和 ES Modules 核心差异与实战指南
一、模块系统基础概念
**CommonJS (CJS)** 是 Node.js 传统模块系统,采用同步加载方式,典型特征:
| |
| module.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'); |
| } |
复制
| |
| if (condition) { import './moduleB.mjs' } |
复制
2. 模块作用域差异
复制
复制
3. 循环引用处理
| |
| exports.loaded = false; |
| const b = require('./b'); |
| console.log('在a中,b.loaded =', b.loaded); |
| exports.loaded = true; |
| |
| |
| exports.loaded = false; |
| const a = require('./a'); |
| console.log('在b中,a.loaded =', a.loaded); |
| exports.loaded = true; |
| |
| |
| |
| |
复制
| |
| import { loaded } from './b.mjs'; |
| export let loaded = false; |
| console.log('在a中,b.loaded =', loaded); |
| loaded = true; |
| |
| |
| import { loaded } from './a.mjs'; |
| export let loaded = false; |
| console.log('在b中,a.loaded =', loaded); |
| loaded = true; |
| |
| |
复制
4. 顶层 this 指向
- CJS 的 this 指向
module.exports
对象
| console.log(this === module.exports); |
复制
- ESM 的 this 为
undefined
(严格模式)
复制
5. 文件扩展名与配置
- CJS 默认识别
.js
和 .cjs
文件 - ESM 需要以下条件之一:
- 文件后缀为
.mjs
- 最近的
package.json
中设置 "type": "module"
复制
6. 引用类型差异
- CJS 导出值拷贝:基本类型值复制,对象类型浅拷贝
| |
| let count = 1; |
| setTimeout(() => { count = 2 }, 100); |
| module.exports = { count }; |
| |
| |
| const { count } = require('./cjs-module'); |
| console.log(count); |
| setTimeout(() => console.log(count), 200); |
复制
| |
| export let count = 1; |
| setTimeout(() => { count = 2 }, 100); |
| |
| |
| import { count } from './esm-module.mjs'; |
| console.log(count); |
| setTimeout(() => console.log(count), 200); |
复制
7. 动态导入能力
- CJS 原生不支持动态导入,但可通过
require
实现 - ESM 支持
import()
动态导入(返回 Promise)
| |
| const module = await import('./module.mjs'); |
| |
| |
| import cjsModule from './cjs-module.cjs'; |
复制
三、日常开发建议
1. 新项目技术选型
- 优先使用 ESM:符合语言标准,支持 Tree Shaking
| |
| { |
| "type": "module", |
| "scripts": { |
| "start": "node --experimental-vm-modules src/index.mjs" |
| } |
| } |
复制
2. 旧项目迁移策略
- 渐进式迁移:
- 将单个文件后缀改为
.mjs
或设置 "type": "module"
- 使用
import/export
语法逐步替换
| |
| import cjsModule from './legacy-module.cjs'; |
复制
3. 模块兼容性处理
- 双格式发布库:通过
package.json
指定双入口
| { |
| "exports": { |
| "import": "./esm-module.mjs", |
| "require": "./cjs-module.cjs" |
| } |
| } |
复制
4. 避免踩坑指南
| |
| import cjsModule from './cjs-module.cjs'; |
复制
- 循环引用处理:ESM 中建议使用函数封装初始化逻辑
| |
| import { initB } from './b.mjs'; |
| export let valueA = '未初始化'; |
| |
| export function initA() { |
| valueA = '初始化A'; |
| initB(); |
| } |
| |
| |
| 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 中引入文件时必须写完整扩展名:
复制
-
默认导出差异:
CJS 的 module.exports
对应 ESM 的默认导出:
| |
| module.exports = { a: 1 }; |
| |
| |
| import cjsModule from './cjs-module.cjs'; |
复制
-
性能优化:
ESM 的静态分析特性使打包工具(如 Rollup)能实现更高效的 Tree Shaking。
五、总结
理解两种模块系统的核心差异,能帮助开发者根据场景合理选择:
- CJS 适合传统 Node.js 项目、需要动态加载的场景
- ESM 适合现代浏览器兼容项目、需要静态分析的构建优化
在混合项目中,通过文件扩展名和 package.json
配置明确模块类型,避免隐式错误。对于长期维护的项目,逐步向 ESM 迁移是更符合技术趋势的选择。