从CommonJS到ES Modules在Node.js项目里混用require和import的避坑实战指南Node.js生态正经历着从CommonJS到ES ModulesESM的渐进式迁移这种过渡期带来的模块混用问题让不少开发者头疼。想象一下这样的场景你正在维护一个大型遗留项目其中90%的代码使用require()但新功能想用import语法或者你引入的某个npm包突然只提供ESM版本而你的项目配置还是CommonJS。这种模块规范鸿沟会导致各种报错比如经典的ReferenceError: require is not defined或SyntaxError: Cannot use import statement outside a module。1. 模块系统的本质差异与兼容性策略CommonJS和ES Modules在设计哲学上就有根本区别。CommonJS是动态加载的运行时模块系统而ES Modules是静态的编译时模块系统。这种差异导致它们在以下方面表现不同加载时机CommonJS的require()是运行时同步加载ESM的import是编译时静态解析缓存机制CommonJS模块是值拷贝ESM模块是实时绑定顶层作用域CommonJS模块的顶层this指向当前模块ESM中指向undefined循环引用处理两者对模块间循环依赖的处理方式完全不同混合使用时的黄金法则在.mjs文件中只能使用import/export在.cjs文件中只能使用require/module.exports在.js文件中行为由最近的package.json中的type字段决定提示Node.js从v12开始支持ESM但直到v14才达到生产可用状态。建议使用Node.js 16版本以获得最稳定的模块互操作体验。2. 项目配置的关键决策点2.1 package.json的type字段这是决定.js文件默认被如何解释的核心配置{ type: module, // 所有.js文件视为ES模块 // 或 type: commonjs // 所有.js文件视为CommonJS默认值 }常见陷阱当type: module时.js文件中的require()调用会抛出ReferenceError即使设置了type: module.cjs文件仍会被解释为CommonJS2.2 文件扩展名的语义扩展名模块类型是否受type字段影响.js由type决定是.mjsESM否.cjsCommonJS否最佳实践在迁移过渡期显式使用.mjs和.cjs扩展名消除歧义对于测试文件建议统一使用.cjs确保测试框架兼容性3. 跨模块规范的互操作技巧3.1 在ESM中加载CommonJS模块ESM可以像加载普通ES模块一样importCommonJS模块// ESM文件中 import cjsModule from ./legacy.cjs; import { method } from ./legacy.cjs; // 对于module.exports.keyvalue形式的导出注意事项CommonJS模块的module.exports会作为ESM的default导出命名导出需要通过import { key }语法访问对应CommonJS中的module.exports.key3.2 在CommonJS中加载ESM模块CommonJS环境必须使用动态import()来加载ESM模块// CommonJS文件中 async function loadESM() { const esModule await import(./modern.mjs); console.log(esModule.default); // 访问默认导出 console.log(esModule.namedExport); // 访问命名导出 }关键限制动态import()返回Promise必须用异步方式处理不能在顶层作用域直接使用await需要包装在async函数中4. 实战中的典型问题与解决方案4.1 解决require is not defined错误当在ESM环境中意外使用require时错误示例// 在typemodule的.js文件或.mjs文件中 const fs require(fs); // ReferenceError: require is not defined修正方案改用ESM导入语法import fs from fs;或者创建兼容层import { createRequire } from module; const require createRequire(import.meta.url); const fs require(fs);4.2 处理无法识别ESM导入问题当CommonJS环境遇到ESM语法时错误示例// 在typecommonjs的.js文件或.cjs文件中 import path from path; // SyntaxError: Cannot use import statement outside a module修正方案改用动态导入const path await import(path);或者将文件重命名为.mjs并设置type: module4.3 模块导出互操作的特殊情况CommonJS导出ESM兼容格式// legacy.cjs module.exports { default: 默认导出, named: 命名导出, __esModule: true // 模拟Babel的互操作标记 };ESM导入时的行为import legacy from ./legacy.cjs; console.log(legacy.default); // 默认导出 console.log(legacy.named); // 命名导出5. 渐进式迁移路线图对于大型项目推荐采用分阶段迁移策略评估阶段使用--experimental-specifier-resolutionnode标志处理无扩展名导入通过exports字段控制包的入口点兼容性基础设施准备{ name: your-package, exports: { .: { require: ./index.cjs, import: ./index.mjs } } }逐个模块迁移先迁移工具类和工具函数再迁移业务逻辑模块最后处理入口文件测试保障使用cross-env NODE_OPTIONS--experimental-vm-modules启用Jest的ESM支持在CI中添加双模块系统的测试矩阵6. 工具链与生态兼容性构建工具支持情况工具ESM支持状态webpack需配置experiments.outputModulerollup原生支持babel需babel/preset-env配置TypeScript需设置module: esnext或node12npm包兼容性检查技巧# 检查包的模块类型 npm view package exports常见问题模式识别当看到ERR_REQUIRE_ESM错误时说明你正尝试require一个纯ESM包ERR_UNSUPPORTED_DIR_IMPORT表示尝试从目录导入而未指定package.json的exports或main7. 性能考量与优化模块系统的选择会影响应用性能加载速度ESM的静态分析允许更好的预加载优化内存使用ESM的实时绑定机制可能减少内存复制启动时间CommonJS的同步加载可能导致启动延迟基准测试建议// benchmark.js import { bench } from vitest; bench(ESM import, async () { await import(./esm-module.mjs); }); bench(CJS require, () { require(./cjs-module.cjs); });8. 调试技巧与问题诊断诊断工具组合使用--loader标志自定义模块加载行为通过NODE_DEBUGmodule环境变量输出模块加载信息利用import.meta.resolve获取模块解析路径典型调试场景// 查看模块如何被解析 console.log(import.meta.resolve(lodash));模块缓存检查// 在CommonJS中 console.log(require.cache); // 在ESM中 import.meta.cache; // 提案阶段9. 未来展望与最佳实践虽然模块混用会带来短期复杂性但遵循这些原则可以平稳过渡新项目直接使用ES Modules作为默认选择旧项目采用增量迁移策略优先转换高频修改的模块库开发同时提供CommonJS和ESM双版本入口团队协作在项目文档中明确模块使用规范工具推荐清单are-the-types-wrong检查npm包的模块类型问题tsup零配置构建支持双模块输出unbuild基于rollup的通用构建工具