从CommonJS到ES Modules:在Node.js项目里混用require和import的避坑实战指南
从CommonJS到ES Modules:在Node.js项目里混用require和import的避坑实战指南
Node.js生态正经历着从CommonJS到ES Modules(ESM)的渐进式迁移,这种过渡期带来的模块混用问题让不少开发者头疼。想象一下这样的场景:你正在维护一个大型遗留项目,其中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文件仍会被解释为CommonJS
2.2 文件扩展名的语义
| 扩展名 | 模块类型 | 是否受type字段影响 |
|---|---|---|
| .js | 由type决定 | 是 |
| .mjs | ESM | 否 |
| .cjs | CommonJS | 否 |
最佳实践:
- 在迁移过渡期,显式使用
.mjs和.cjs扩展名消除歧义 - 对于测试文件,建议统一使用
.cjs确保测试框架兼容性
3. 跨模块规范的互操作技巧
3.1 在ESM中加载CommonJS模块
ESM可以像加载普通ES模块一样importCommonJS模块:
// ESM文件中 import cjsModule from './legacy.cjs'; import { method } from './legacy.cjs'; // 对于module.exports.key=value形式的导出注意事项:
- CommonJS模块的
module.exports会作为ESM的default导出 - 命名导出需要通过
import { key }语法访问,对应CommonJS中的module.exports.key
3.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时:
错误示例:
// 在type=module的.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语法时:
错误示例:
// 在type=commonjs的.js文件或.cjs文件中 import path from 'path'; // SyntaxError: Cannot use import statement outside a module修正方案:
- 改用动态导入:
const path = await import('path'); - 或者将文件重命名为
.mjs并设置"type": "module"
4.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-resolution=node标志处理无扩展名导入 - 通过
"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.outputModule |
| rollup | 原生支持 |
| babel | 需@babel/preset-env配置 |
| TypeScript | 需设置module: esnext或node12 |
npm包兼容性检查技巧:
# 检查包的模块类型 npm view <package> exports常见问题模式识别:
- 当看到
ERR_REQUIRE_ESM错误时,说明你正尝试require一个纯ESM包 ERR_UNSUPPORTED_DIR_IMPORT表示尝试从目录导入而未指定package.json的exports或main
7. 性能考量与优化
模块系统的选择会影响应用性能:
- 加载速度: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_DEBUG=module环境变量输出模块加载信息 - 利用
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的通用构建工具
