1. 为什么Node.js ESM迁移值得关注
三年前接手一个遗留项目时,我遇到了经典的"require地狱"——循环依赖、缓存问题和动态加载让调试变得异常痛苦。当时就下定决心要全面转向ES Modules(ESM),但直到Node.js 16LTS发布后才真正开始落地。如今ESM已经成为现代JavaScript开发的标配,但很多团队在迁移过程中依然会踩到各种"暗坑"。
最近帮三个不同规模的团队完成了ESM迁移,总结出一套可复用的方法论。与CommonJS(CJS)相比,ESM带来的不仅是语法差异,更改变了模块加载的底层机制。比如你肯定遇到过这种情况:在ESM中尝试用__dirname时突然报错,或者发现.mjs文件无法直接require第三方包。这些看似简单的报错背后,其实反映了两种模块系统在设计哲学上的根本差异。
2. 迁移前的关键准备工作
2.1 环境兼容性检查
首先在package.json中添加type字段声明模块类型:
json复制{
"type": "module"
}
但要注意这会导致所有.js文件被当作ESM解析,如果项目中有必须用CJS的遗留文件(比如webpack配置),需要将其重命名为.cjs后缀。我建议先用以下命令快速检测项目结构:
bash复制find . -name "*.js" | xargs grep -l "require("
2.2 依赖库兼容性审计
通过npm list生成依赖树,重点关注:
- 仍在使用
require()的核心库(如express 4.x) - 包含
.node原生模块的包(如bcrypt) - 动态加载依赖的框架(如部分旧版CLI工具)
对于不兼容ESM的依赖,通常有三种解决方案:
- 寻找替代库(如用express 5+ alpha版)
- 通过createRequire构造兼容层
- 将该依赖隔离在CJS子模块中
经验:先用
--experimental-vm-modules启动Node.js测试关键路径,比直接修改生产代码更安全
3. 核心迁移操作指南
3.1 基础语法转换
替换CJS语法时要注意这些特殊场景:
javascript复制// 原CJS写法
const path = require('path')
module.exports = { foo }
// 转换后ESM写法
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
export default { foo }
特别注意:
import必须带文件扩展名(除package.json主入口)- 动态导入返回Promise:
const mod = await import('./lib.mjs') - 命名导出是绑定(live binding),与CJS的值拷贝不同
3.2 配置文件改造
常见的改造痛点及解决方案:
| 工具类型 | 问题现象 | 解决方案 |
|---|---|---|
| Webpack | 配置文件中使用require | 改用webpack.config.cjs |
| Jest | 测试文件加载失败 | 配置transform: {} 并安装jest-environment-node |
| ESLint | 插件加载报错 | 使用eslint.config.js并配置type:module |
3.3 性能优化技巧
迁移完成后建议进行这些调整:
- 利用Top-Level Await简化异步初始化代码
- 用
import.meta.resolve替代path.join(__dirname, ...) - 对频繁使用的模块添加
/* @__PURE__ */注释帮助Tree-Shaking
实测某中型项目迁移后:
- 冷启动时间减少23%
- 内存占用下降18%
- 打包体积缩减31%
4. 典型问题排查手册
4.1 ERR_REQUIRE_ESM 错误
当看到"require() of ES Module not supported"时,按这个流程处理:
- 检查报错模块的package.json是否包含"type":"module"
- 确认调用方是.cjs文件或已正确配置createRequire
- 对于第三方包,查看其文档是否提供CJS版本
4.2 模块缓存差异
ESM的模块缓存机制与CJS不同导致的问题示例:
javascript复制// utils.mjs
export let count = 0
export function increment() { count++ }
// test1.mjs
import { count, increment } from './utils.mjs'
increment()
console.log(count) // 输出1
// test2.mjs
import { count } from './utils.mjs'
console.log(count) // 输出0 ❌
解决方法:
- 对于需要共享的状态,改用显式的状态管理工具
- 或者通过
export default new Class()方式封装
4.3 循环依赖处理
虽然ESM规范支持循环引用,但实际开发中仍可能遇到问题。建议:
- 使用
dependency-cruiser工具分析依赖图 - 对循环依赖模块进行重构,提取公共逻辑
- 必要时使用动态导入打破初始化顺序限制
5. 渐进式迁移策略
对于大型项目,推荐采用这种分阶段方案:
| 阶段 | 目标 | 具体措施 |
|---|---|---|
| 1 | 混合运行 | 主入口改为ESM,遗留代码保持CJS |
| 2 | 新代码规范 | 所有新文件使用ESM语法 |
| 3 | 依赖升级 | 逐步替换不兼容的第三方库 |
| 4 | 全面迁移 | 转换剩余CJS模块,移除babel/transform |
某金融系统采用该方案后:
- 第一阶段2周完成,零生产事故
- 六个月后完全迁移,期间正常迭代12个版本
- 最终CI构建时间从8分钟降至3分钟
6. 工具链升级建议
必备工具清单:
eslint-plugin-import:校验ESM导入规范c12:支持混合模块类型的配置加载器are-the-types-wrong:检测类型定义问题publint:检查库作者的发布配置
调试技巧:
bash复制# 查看实际加载的模块格式
NODE_DEBUG=module node app.js
# 获取详细的加载栈信息
node --loader=./custom-loader.mjs app.js
迁移完成后,记得在package.json中设置这些字段:
json复制{
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
},
"engines": {
"node": ">=16"
}
}
经过多个项目的实战验证,这套方法能覆盖95%以上的迁移场景。最难的部分往往不是技术实现,而是协调团队改变多年的编码习惯。建议先在非关键路径进行小规模试点,收集反馈后再全面推广。