1. 模块化演进的历史背景
Node.js 自诞生以来就采用 CommonJS 作为其模块系统标准。这种同步加载的模块化方案在当时的环境下表现出色,require() 和 module.exports 的简单组合让开发者能够轻松组织代码。但随着前端生态的快速发展,ES Modules (ESM) 作为 JavaScript 语言标准的一部分逐渐成为主流,这导致 Node.js 生态出现了长达数年的模块系统分裂。
CommonJS 和 ESM 有几个关键差异点:
- 加载机制:CommonJS 是同步加载,ESM 是异步加载
- 解析时机:CommonJS 是运行时动态解析,ESM 是静态解析
- 顶层行为:CommonJS 中顶层 this 指向 module.exports,ESM 中顶层 this 是 undefined
- 文件扩展:CommonJS 主要使用 .js,ESM 需要明确使用 .mjs 或设置 type: "module"
这些差异使得两种模块系统长期无法直接互通,开发者不得不使用各种变通方案。我在实际项目中经常遇到这样的场景:一个使用 ESM 的前端库想要在 Node.js 环境中使用,不得不通过打包工具转换或者放弃使用。
2. Node.js 的模块化兼容之路
Node.js 团队从 v12 开始实验性支持 ESM,经历了多个版本的迭代和改进。早期实现存在诸多限制,比如:
- 必须使用 .mjs 扩展名
- 需要添加 --experimental-modules 标志
- 与 CommonJS 互操作需要特殊处理
随着 Node.js v14 的发布,ESM 支持开始趋于稳定,但真正的突破发生在 v16 和 v18 版本。这两个 LTS 版本引入了关键的改进:
- 取消了对 .mjs 扩展名的强制要求
- 支持通过 package.json 的 type 字段指定模块类型
- 改进了 import/require 的互操作性
我清楚地记得在 v16 项目中第一次成功混用两种模块系统时的兴奋。当时我们正在将一个大型代码库逐步迁移到 ESM,这个版本的改进让我们可以按模块逐个迁移而不必一次性重写整个项目。
3. require() 加载 ESM 的技术实现
Node.js 现在允许通过 require() 加载 ESM 模块,这背后有几个关键技术点:
3.1 模块加载器的统一处理
Node.js 内部现在使用统一的模块加载器来处理两种模块系统。当遇到 require('esm-module') 时:
- 解析器会先检查目标模块的 package.json
- 如果 type 字段为 "module" 或文件扩展名为 .mjs,则按 ESM 处理
- 加载器会创建一个特殊的模块包装器
- 在内部执行 ESM 到 CommonJS 的适配
这个过程中最巧妙的是异步到同步的转换。由于 require() 本质上是同步操作,而 ESM 是异步加载的,Node.js 通过在内部使用 Async Hook 和 Promise 来解决这个问题。
3.2 默认导出的特殊处理
ESM 的默认导出(export default)在 CommonJS 中会被转换为 exports.default。这意味着在 require() ESM 模块时,你需要这样使用:
javascript复制const esmModule = require('./esm-module');
// 访问默认导出
console.log(esmModule.default);
这种转换虽然解决了兼容性问题,但在使用体验上并不理想。我建议在混合使用的项目中,ESM 模块最好使用命名导出(named exports)而不是默认导出,这样可以保持更好的互操作性。
4. 实际项目中的最佳实践
基于多个项目的迁移经验,我总结出以下实践建议:
4.1 渐进式迁移策略
对于现有 CommonJS 项目,推荐采用渐进式迁移:
- 先将 package.json 中的 type 设置为 "module"
- 逐个文件将 require 改为 import
- 对于暂时无法迁移的第三方模块,使用动态 import()
javascript复制// 对于仍使用 CommonJS 的第三方模块
import('cjs-module').then(module => {
// 使用模块
});
4.2 工具链配置
现代工具链对 ESM 的支持已经相当完善:
- TypeScript: 设置 module: "esnext"
- Babel: 使用 @babel/preset-env 的 modules: false 选项
- ESLint: 确保使用支持 ESM 的解析器
在配置这些工具时,我遇到的一个常见问题是文件扩展名的处理。建议在项目根目录添加 .js 和 .mjs 的 eslint 和 typescript 配置覆盖,确保工具能正确识别模块类型。
4.3 性能考量
虽然 ESM 的异步特性理论上更适合现代应用,但在实际测量中我们发现:
- 冷启动时 ESM 加载稍慢(约 10-15%)
- 热加载和长期运行性能优于 CommonJS
- 内存占用通常更低
在需要极致启动性能的场景(如 CLI 工具),可以考虑保留部分核心模块为 CommonJS。我们有一个高频调用的 CLI 工具,通过这种混合使用将启动时间减少了约 20%。
5. 常见问题与解决方案
5.1 __dirname 不可用问题
ESM 中不再有 CommonJS 的 __dirname 和 __filename。替代方案:
javascript复制import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
5.2 JSON 导入差异
CommonJS 中可以直接 require JSON 文件,ESM 中需要特殊处理:
javascript复制// CommonJS
const data = require('./data.json');
// ESM
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const data = require('./data.json');
5.3 第三方模块兼容性
并非所有 npm 包都已支持 ESM。遇到问题时可以:
- 检查包是否有 ESM 版本(查看 package.json 的 exports 字段)
- 使用动态 import() 加载
- 考虑寻找替代方案
我们在迁移一个依赖密集的项目时,发现有约 15% 的依赖包需要特殊处理。通过创建兼容层和逐步替换,最终实现了完全迁移。
6. 未来展望与建议
Node.js 的模块系统统一标志着 JavaScript 生态的一个重要里程碑。对于新项目,我强烈建议直接使用 ESM。它不仅更符合语言标准,还能更好地与现代工具链和浏览器环境集成。
对于维护现有项目的开发者,不必急于一次性迁移。可以按照以下优先级逐步推进:
- 先确保测试和工具链支持 ESM
- 从新功能和边缘模块开始迁移
- 最后处理核心业务逻辑
我在最近的一个微服务项目中采用了这种策略,团队成员的接受度很高,迁移过程几乎没有造成功能中断。整个迁移历时约 3 个月,最终代码的可维护性和性能都有显著提升。