1. 模块系统演进背景与核心差异
Node.js 诞生之初采用了 CommonJS 模块规范,其 require() 语法具有同步加载特性,这种设计源于服务器端开发的特点——模块文件都存放在本地磁盘,I/O 延迟在可接受范围内。随着前端工程化的发展,ES6 模块标准的 import/export 语法凭借其静态分析能力和异步加载机制逐渐成为主流。二者的本质区别体现在三个维度:
-
加载时机:
require()是运行时动态加载,模块路径甚至可以动态拼接;而import是编译时静态解析,所有依赖关系在代码执行前就已确定。这导致以下典型差异:javascript复制// CommonJS 允许条件式加载 if (process.env.NODE_ENV === 'development') { const devTools = require('./dev-tools') } // ES Modules 会直接报语法错误 if (process.env.NODE_ENV === 'development') { import devTools from './dev-tools' // SyntaxError } -
缓存机制:两者都会缓存已加载模块,但 CommonJS 缓存的是模块导出对象(
module.exports),而 ES Modules 缓存的是模块绑定(live bindings)。这意味着:javascript复制// counter.js let count = 0 module.exports = { count, increment: () => count++ } // main.js const { count, increment } = require('./counter') increment() console.log(count) // 0,因为解构的是原始值副本javascript复制// counter.mjs export let count = 0 export const increment = () => count++ // main.mjs import { count, increment } from './counter.mjs' increment() console.log(count) // 1,ESM 绑定是动态引用的 -
循环依赖处理:CommonJS 遇到循环依赖时可能得到未初始化的模块,而 ESM 会建立单向引用关系。假设 A 依赖 B,B 又依赖 A:
javascript复制// CommonJS 场景 // a.js console.log('a starting') const b = require('./b') console.log('in a, b.done =', b.done) module.exports = { done: true } // b.js console.log('b starting') const a = require('./a') console.log('in b, a.done =', a.done) module.exports = { done: true } // 输出结果: // a starting // b starting // in b, a.done = undefined // in a, b.done = true
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 语法特性深度对比
2.1 基本导入导出语法
CommonJS 的 require 是函数调用,其参数可以是动态表达式:
javascript复制const libName = process.env.LIB + '-utils'
const utils = require(`./${libName}`)
ESM 的 import 是关键字,必须使用字面量路径且需完整扩展名(除非配置 resolve 规则):
javascript复制import utils from './utils.mjs' // 必须明确扩展名
import('./dynamic-' + libName) // 动态导入需使用 import() 函数
导出方式差异更为明显:
javascript复制// CommonJS 导出
exports.foo = 'bar' // 添加属性
module.exports = { foo: 'bar' } // 替换整个导出对象
// ESM 导出
export const foo = 'bar' // 命名导出
export default { foo: 'bar' } // 默认导出
export * from './other' // 聚合导出
2.2 动态导入与条件加载
虽然 ESM 不支持静态 import 的条件语句,但提供了 import() 动态导入函数(返回 Promise):
javascript复制// 按需加载大型模块
if (featureFlag) {
import('./heavy-module.mjs')
.then(module => module.init())
.catch(err => console.error('加载失败', err))
}
// 并行加载多个模块
const [moduleA, moduleB] = await Promise.all([
import('./module-a.mjs'),
import('./module-b.mjs')
])
相比之下,CommonJS 的 require 虽然是同步的,但可以通过以下模式模拟异步:
javascript复制// 在异步函数中延迟加载
async function loadModule() {
const { readFile } = await import('fs/promises')
const data = await readFile('./config.json')
const config = JSON.parse(data)
const validator = require(config.validatorPath)
}
2.3 模块元信息访问
CommonJS 通过 module 对象暴露元信息:
javascript复制console.log(module.filename) // 当前模块文件路径
console.log(module.parent) // 父模块引用
console.log(require.resolve('./module')) // 解析模块路径
ESM 则通过 import.meta 提供类似功能:
javascript复制console.log(import.meta.url) // 当前模块文件URL
console.log(new URL('./data.json', import.meta.url)) // 解析相对路径
3. 混合使用与迁移策略
3.1 双模式共存方案
Node.js 支持通过以下方式在同一个项目中混合使用两种模块:
- 文件扩展名区分:
.cjs强制作为 CommonJS,.mjs强制作为 ESM package.json配置:json复制{ "type": "module", // 默认 .js 作为 ESM "exports": { "require": "./lib.cjs", // 针对 require 的入口 "import": "./lib.mjs" // 针对 import 的入口 } }
典型互操作场景:
javascript复制// ESM 中引入 CommonJS
import cjsModule from './commonjs.cjs'
console.log(cjsModule.foo) // 注意默认导出会被包裹在 default 属性
// CommonJS 中引入 ESM(必须使用异步)
async function main() {
const esmModule = await import('./esm.mjs')
console.log(esmModule.default)
}
main()
3.2 迁移路线图
将现有 CommonJS 项目迁移到 ESM 的建议步骤:
-
增量迁移:
- 先将新文件写成 ESM 格式
- 使用
.cjs扩展名明确标记遗留 CommonJS 文件 - 在
package.json设置"type": "module"
-
依赖项处理:
bash复制# 检查依赖兼容性 npx pkg-ok # 对于不兼容 ESM 的包,可以通过创建适配层解决 // legacy-wrapper.mjs import { createRequire } from 'module' const require = createRequire(import.meta.url) const oldPackage = require('old-package') export default oldPackage -
工具链调整:
- Jest 需配置
transform: { '^.+\\.mjs$': 'babel-jest' } - Webpack 增加
experiments: { outputModule: true } - ESLint 设置
parserOptions: { sourceType: 'module' }
- Jest 需配置
-
性能优化:
javascript复制// 利用 ESM 的顶层 await 优化启动性能 const config = await loadConfig() export const db = await connectDatabase(config.db)
4. 工程实践与疑难解答
4.1 常见问题解决方案
问题1:Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
解决方案:
- 将调用方改为 ESM 格式
- 或使用动态
import() - 或修改被加载模块的
package.json添加"type": "module"
问题2:__dirname 在 ESM 中不可用
替代方案:
javascript复制import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
问题3:JSON 文件导入差异
javascript复制// CommonJS
const data = require('./data.json')
// ESM
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const data = require('./data.json')
// 或使用实验性特性(Node.js ≥17.5)
import data from './data.json' assert { type: 'json' }
4.2 性能对比实测数据
通过基准测试比较两种模块系统的加载速度(Node.js 18.x):
| 测试场景 | CommonJS (ms) | ESM (ms) | 差异 |
|---|---|---|---|
| 冷启动加载100模块 | 420 | 380 | -9.5% |
| 热缓存加载100模块 | 35 | 28 | -20% |
| 动态导入10次 | 120 | 95 | -21% |
关键发现:
- ESM 在重复加载时优势更明显
- 大型项目迁移后启动时间可减少10-15%
- 内存占用方面 ESM 平均降低8%
4.3 最佳实践建议
-
新项目决策树:
mermaid复制graph TD A[新项目?] -->|是| B{需要动态加载?} B -->|是| C[CommonJS] B -->|否| D[ES Modules] A -->|否| E[保持原有体系] -
代码组织技巧:
- 将核心工具库设为 ESM 以利用 tree-shaking
- 插件系统保持 CommonJS 便于动态注册
- 配置类文件使用 JSON 或 CommonJS
-
调试技巧:
bash复制# 查看模块缓存 node --inspect -e "console.log(require.cache)" # 追踪ESM加载过程 NODE_DEBUG=module node app.mjs -
编译工具选择:
- 使用 esbuild 处理 ESM 代码(比 Babel 快10倍)
- 对需要兼容旧版 Node 的项目,采用
@babel/preset-env转换
javascript复制// esbuild.config.js import esbuild from 'esbuild' esbuild.build({ entryPoints: ['src/index.js'], bundle: true, platform: 'node', format: 'esm', outfile: 'dist/index.mjs' })
