1. 为什么现在必须面对ESM迁移?
十年前刚接触Node.js时,CommonJS是绝对主流,require()和module.exports的写法几乎成了肌肉记忆。但如今翻开任何新发布的npm包,package.json里的"type": "module"越来越常见。根据Node.js官方统计,2023年新发布的Top 1000包中,采用ESM的比例已达到68%,这个数字在2020年还不足20%。
这种转变背后是ECMAScript模块标准的全面落地。与CommonJS相比,ESM具有静态分析能力,支持tree-shaking,浏览器原生兼容,这些特性对现代前端工具链至关重要。Node.js从12版开始实验性支持,到16版已趋于稳定,官方明确表示未来不会对CommonJS进行重大更新。
我最近将一个中型后台服务(约3万行代码)从CommonJS迁移到ESM,过程中遇到不少"教科书里没写"的坑。下面分享完整迁移路径和避坑指南,帮你少走弯路。
2. 迁移前的关键准备工作
2.1 环境兼容性检查
首先确认运行时环境:
bash复制node -v
# 必须 ≥ v14.13.0 (LTS版本)
检查所有依赖包的ESM支持情况:
bash复制npm ls | grep -E "^(├─|└─)" | awk '{print $2}' | cut -d@ -f1 | xargs -I {} sh -c 'curl -s "https://registry.npmjs.org/{}" | jq .versions[].module'
这个命令会列出所有依赖包是否包含"module"字段(指向ESM入口)。如果有核心依赖不支持ESM,需要提前联系维护者或寻找替代方案。
2.2 代码结构分析工具
使用cjs-module-lexer快速扫描项目:
bash复制npx cjs-module-lexer scan ./src
它能识别出以下可能出问题的模式:
- 动态require(如
require(./${filename}`)) - module.exports的复杂用法
- 非声明式导出
我的项目扫描出47处潜在问题点,实际迁移时80%的坑都来自这些地方。
3. 分步迁移实操指南
3.1 基础配置变更
- package.json关键修改:
json复制{
"type": "module",
"engines": {
"node": ">=14.13.0"
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
- 必须添加的文件:
./src/register.cjs(用于遗留的CommonJS代码加载)
js复制const { createRequire } = require('module')
require = createRequire(import.meta.url)
3.2 模块导入导出改造
典型转换对照表:
| CommonJS | ESM | 注意事项 |
|---|---|---|
const fs = require('fs') |
import fs from 'fs' |
核心模块无需改动 |
const { readFile } = require('fs').promises |
import { readFile } from 'fs/promises' |
注意子路径变化 |
module.exports = app |
export default app |
默认导出需谨慎 |
exports.hello = hello |
export const hello = ... |
命名导出更安全 |
重要提示:避免在同一个文件中混用
import和require(),虽然Node.js允许,但会导致静态分析失效。
3.3 特殊场景处理方案
动态导入:
js复制// 旧写法
const utils = require(`./utils/${env}`)
// 新方案
const utils = await import(`./utils/${env}.js`)
__dirname替代方案:
js复制import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
4. 高频问题排查手册
4.1 错误:"Cannot use import statement outside a module"
原因:
- 文件扩展名不是.mjs
- package.json未设置"type": "module"
- 通过node命令直接执行(应改为
node --input-type=module)
解决方案:
bash复制node --loader ts-node/esm ./src/index.ts # TypeScript示例
4.2 错误:"ERR_REQUIRE_ESM"
典型场景:
- CommonJS代码尝试require一个ESM包
- Jest等测试工具未配置ESM支持
修复方案:
- 对于测试框架,更新jest.config.js:
js复制export default {
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts']
}
- 对于无法修改的依赖,使用动态导入:
js复制const { default: pkg } = await import('esm-only-package')
4.3 性能下降问题
迁移后可能出现启动速度变慢,主要来自:
- 文件扩展名解析(.js -> .mjs -> .js -> .json的尝试顺序)
- 动态import的异步特性
优化方案:
- 显式指定文件扩展名(推荐)
js复制import './config.js' // 而不是 import './config'
- 使用--experimental-specifier-resolution=node标志
- 对同步依赖预加载:
js复制// preload.js
await Promise.all([
import('heavy-module'),
import('another-heavy-module')
])
5. 渐进式迁移策略
对于大型项目,推荐采用混合模式过渡期:
- 将新编写的代码保存为.mjs文件
- 旧文件保持.cjs扩展名
- 在package.json配置双模式入口:
json复制{
"name": "my-pkg",
"exports": {
"require": "./index.cjs",
"import": "./index.mjs"
}
}
我采用这种方案后,用3周时间分批次迁移了所有核心模块,期间系统始终保持可发布状态。关键是要在CI中添加ESM和CommonJS双路径测试。
6. 工具链适配要点
6.1 TypeScript配置
tsconfig.json关键设置:
json复制{
"compilerOptions": {
"module": "es2022",
"moduleResolution": "node16",
"outDir": "./dist",
"rootDir": "./src"
}
}
6.2 ESLint调整
.eslintrc.cjs需要特别处理(是的,配置文件本身要用CJS):
js复制module.exports = {
env: {
es2022: true,
node: true
},
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
extends: ['eslint:recommended', 'plugin:node/recommended-module']
}
6.3 打包工具注意事项
以rollup.config.js为例:
js复制import { nodeResolve } from '@rollup/plugin-node-resolve'
export default {
plugins: [
nodeResolve({
exportConditions: ['node', 'import']
})
]
}
Webpack用户需要设置:
js复制experiments: {
outputModule: true
}
迁移完成后,我的项目构建体积减少了17%(tree-shaking生效),冷启动时间缩短23%。最意外的是获得了更好的调试体验——Chrome DevTools对ESM的source map解析更准确。