说到TypeScript的模块化,很多开发者其实都有过“半夜惊醒”的时刻——明明本地跑得好好的,一部署到服务器或者换个环境就炸了。尤其是最近两年,随着ES Modules(ESM)逐渐成为行业标准,越来越多的团队开始尝试从传统的CommonJS(CJS)迁移过来。这听起来是个好事,毕竟ESM是原生支持、静态分析友好、树摇(Tree-shaking)必备的未来趋势,但真干起来,坑多得能让你怀疑人生。
我见过太多人掉进同一个坑里:配置改了一堆,结果导入导出全乱套;或者为了追求新特性,忽略了Node.js版本兼容性,导致生产环境直接报错。今天咱们不聊那些干巴巴的理论,就结合实战经验,把CommonJS迁移到ESM这条路上的那些“隐蔽地雷”一个个挖出来,顺便教你怎么平滑过渡,不让业务中断。
先搞清楚:为什么非要迁移?
在动手改代码之前,你得先明白迁移的价值在哪,不然团队里肯定有人抵触:“咱们现在跑得好好的,为什么要折腾?”
ESM相比CJS有几个核心优势,这些优势在大型项目中尤为明显:
- 静态结构:ESM的import/export是静态的,编译工具可以在构建时精确分析依赖关系,实现高效的Tree-shaking。而CJS的require是动态执行的,工具很难确定哪些模块真的被用到了,很多代码白白打包进去,体积越来越大。
- 原生支持:Node.js从12.x开始逐步支持ESM,v14+更加稳定,v18+更是把ESM当作默认推荐方案。浏览器更是原生支持ESM,不用任何转译就能用。这意味着你写一份代码,前后端都能跑得更顺畅。
- 模块作用域更清晰:ESM严格区分模块顶层和函数作用域,不会像CJS那样有变量提升的坑,调试起来更直观。
- 动态导入:ESM支持
import()动态加载,适合做懒加载、代码分割,对性能优化帮助很大。
当然,优势归优势,迁移过程本身就是一场硬仗。我得先帮你把现有的项目结构摸清楚,才能对症下药。
迁移前的准备:摸清家底
别一上来就改配置,先做个全面的“体检”。我给你列个清单,咱们一步步来:
1. 检查当前模块系统的混合情况
很多项目不是纯CJS,而是CJS和ESM混用,或者某些依赖用了CJS,某些用了ESM。这种情况最麻烦。先用下面这个脚本扫一遍:
# 查找所有.js和.ts文件,统计require和import的使用情况
find . -name "*.ts" -o -name "*.js" | xargs grep -l "require(" | wc -l
find . -name "*.ts" -o -name "*.js" | xargs grep -l "^import " | wc -l
如果CJS的require占比超过70%,那迁移工作量会比较大;如果本身就混杂了ESM,那相对容易一些。
2. 梳理第三方依赖
这是最容易踩坑的地方。有些包是纯CJS,有些是纯ESM,还有些是双 exports 的(package.json里同时有"type": "module"和"main"指向CJS)。
打开你的package.json,检查dependencies和devDependencies。对于关键依赖,去npm官网或者GitHub看看它们的模块系统支持情况。比如lodash是CJS,react已经是ESM为主了。
这里有个实用技巧:用check-dependencies这个工具自动生成报告:
npx check-dependencies
它会告诉你每个依赖是CJS、ESM还是混合。把报告存下来,后面迁移时对着查。
3. 确认Node.js版本
ESM的支持和Node版本强相关:
- Node 12.x:实验性支持,需要
--experimental-modules标志 - Node 14.x:稳定支持,但部分功能还是实验性的
- Node 16.x+:完全支持,推荐使用
- Node 18.x+:ESM成为默认,强烈建议升级
如果你的项目还在跑Node 14以下,得先升级。我见过有人强行在旧版本上用ESM,结果各种莫名其妙的问题,折腾了一周才找到原因。
4. 备份当前代码
别嫌麻烦,先建个分支或者打个tag:
git checkout -b migrate-to-esm
git tag v1.0.0-cjs
这样万一迁移翻车了,还能回退。
核心配置文件改造
这是迁移的“心脏”地带,改对了,后面事半功倍;改错了,步步皆错。
1. package.json的”type”字段
最直接的改动是在package.json里加一行:
{
"name": "my-project",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"exports": {
".": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
加了"type": "module"之后,项目里所有.js文件都会被当作ESM处理。但注意,TypeScript源码是.ts文件,编译后的.js才会生效。所以这个字段主要影响你运行时加载的依赖和编译输出。
2. tsconfig.json的配置调整
TypeScript的模块系统配置是关键,得仔细调整:
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
重点解释几个字段:
"module": "ESNext":让TypeScript输出ESM格式的代码。如果是Node环境,也可以设为"NodeNext",这是TypeScript 4.7+引入的,更贴合Node的ESM规范。"moduleResolution": "node":兼容Node的模块解析策略,对处理CJS依赖很重要。如果用"bundler",可能需要配合Vite或Webpack。"esModuleInterop": true:这个必须开!它能让你用import _ from 'lodash'这种写法去导入CJS模块,底层会自动处理兼容。"allowSyntheticDefaultImports": true:配合上面那个,允许默认导入。
3. 编译输出的路径处理
CJS迁移到ESM后,文件扩展名也是个坑。ESM导入文件时必须带扩展名,而CJS不用。你得在编译配置里处理好这个问题。
比如,你有个导入:
import { helper } from './utils';
编译成ESM后,如果没有扩展名,Node会报错。解决办法有两个:
- 用TypeScript的
paths配置配合别名,让工具自动补全 - 或者用
tsup、esbuild这样的现代打包工具,它们能自动处理扩展名
我个人推荐用tsup,配置简单,处理ESM很优雅:
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
sourcemap: true,
clean: true,
});
这样编译出来的ESM代码,导入语句会自动带上.js扩展名,省去手动改的麻烦。
代码层面的改造实战
配置文件改完,接下来是代码。这里头的坑最多,我得一个个掰开揉碎讲。
1. 文件扩展名问题
前面说了,ESM导入必须带扩展名。如果你有很多地方用了:
import { foo } from './bar';
得改成:
import { foo } from './bar.js';
这在大型项目里是个体力活。可以用脚本批量替换:
find src -name "*.ts" -exec sed -i '' "s|from '\.\([^']*\)'|from '\1.js'|g" {} +
注意,这个命令会把所有相对路径导入都加上.js,但要注意排除那些已经是.js或者没有扩展名的情况。最好先小范围测试。
2. 默认导入和命名导入的兼容
CJS里常用module.exports = something,然后ESM用import something from 'module'。这在ESM里是不直接的,因为ESM的默认导入对应的是CJS的exports.default。
还好有esModuleInterop帮忙,但有些老库可能没处理好。比如:
// CJS库
module.exports = { a: 1, b: 2 };
ESM导入时:
import lib from './cjs-lib';
// lib 可能是 { default: { a: 1, b: 2 } },也可能直接是 { a: 1, b: 2 }
如果遇到这个问题,可以试试:
import * as lib from './cjs-lib';
// 或者
const lib = require('./cjs-lib'); // 在ESM里用createRequire
createRequire是Node提供的API,专门解决ESM里需要CJS的问题:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const cjsModule = require('./cjs-module');
3. 动态导入的写法变化
CJS里动态加载用require.ensure或者System.import,ESM里直接用import():
// 原来的CJS懒加载
const loadModule = () => {
require.ensure([], (require) => {
const module = require('./heavy-module');
module.init();
});
};
// 现在的ESM写法
const loadModule = async () => {
const module = await import('./heavy-module');
module.init();
};
注意,import()返回的是Promise,所以得用async/await或者.then()。这个改动不大,但得检查所有调用处。
4. 路径别名和绝对导入
很多项目用了路径别名,比如@/utils指向src/utils。CJS下靠Webpack或TypeScript的paths配置,ESM下需要额外处理。
如果用Vite或Rollup,配置很简单:
// vite.config.ts
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
});
如果用Node原生运行,得用node --import配合路径映射,或者用pkg-types这样的库。这块比较折腾,建议优先用打包工具过渡。
5. 循环依赖的处理
CJS对循环依赖比较宽容,因为它是动态执行的。ESM是静态分析的,循环依赖直接报错:
RangeError: Maximum call stack size exceeded
或者报undefined。这种情况得重构代码,打破循环。常见手法是提取公共依赖到一个新模块,或者用延迟导入:
// 打破循环
import { heavyFunction } from './heavy-module';
export function lightFunction() {
return heavyFunction();
}
把依赖拆开,各自导入,避免互相引用。
运行时环境的坑
代码改完了,跑起来才发现还有问题。运行时是迁移的最后关口,也是最容易忽略的地方。
1. Node.js版本的坑
前面说了,Node 14以下用ESM得加--experimental-modules。但即使升级到16+,有些全局变量还是不一样。比如__dirname和__filename在ESM里不存在了,得用import.meta.url替代:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
写个小工具函数统一管理:
// utils.ts
import { fileURLToPath } from 'url';
import { dirname } from 'path';
export const getDirname = (url: string) => dirname(fileURLToPath(url));
export const getFilename = (url: string) => fileURLToPath(url);
然后到处调用:
import { getDirname, getFilename } from './utils';
const __dirname = getDirname(import.meta.url);
const __filename = getFilename(import.meta.url);
2. 配置文件的支持
很多工具的配置是config.js或者webpack.config.js,这些在ESM里不能直接用。得改成config.mjs或者webpack.config.mjs,或者在package.json里指定type。
如果项目用了.json文件导入,ESM默认不支持,得开resolveJsonModule(前面tsconfig里已经开了)。
3. 测试框架的兼容
Jest、Mocha、Vitest这些测试框架对ESM的支持程度不一样。Jest 27+支持ESM,但得配.cjs后缀或者用--experimental-vm-modules。Vitest天生支持ESM,体验更好。
如果你用Jest,配置一下:
// package.json
{
"jest": {
"transform": {
"^.+\\.tsx?$": ["ts-jest", { "useESM": true }]
},
"extensionsToTreatAsEsm": [".ts"]
}
}
然后运行测试时加标志:
NODE_OPTIONS='--experimental-vm-modules' npx jest
4. TypeScript类型检查在ESM下的表现
ESM的静态特性会让TypeScript的类型检查更严格。比如,以前CJS下一些隐式转换能过,ESM下可能报错。这时候得看具体的错误信息,往往是个小改动。
一个常见问题是export =和export default的混用。CJS里export =很常见,ESM里不支持,得改成export default或者命名导出。
// 旧CJS写法
export = MyModule;
// 新ESM写法
export default MyModule;
// 或者
export { MyModule as default };
迁移策略:从小步快跑到全面切换
说了这么多,具体怎么操作?我推荐分阶段迁移,别一把梭哈。
阶段一:试点模块
选一个小的、独立性强的模块,比如工具函数库,先迁移它。确认配置没问题,测试通过,再推广到整个项目。
阶段二:并行运行
在迁移过程中,保持CJS和ESM代码共存。用tsup或者Webpack做双输出,先让ESM版本跑起来,CJS版本作为回退。
// tsup.config.ts
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
outDir: 'dist',
});
这样,旧依赖还能用CJS版本,新代码用ESM,慢慢过渡。
阶段三:全面切换
当所有模块都迁移完,测试全覆盖,再彻底去掉CJS输出。更新package.json的main和module字段指向ESM版本。
阶段四:清理和验证
跑一遍完整的测试套件,检查性能指标(Bundle大小、启动时间),确保没有隐性回归。
常见问题排查清单
迁移过程中遇到问题,按这个清单排查:
- 报错
Cannot use import statement outside a module:检查package.json有没有"type": "module",或者文件是不是用了.mjs扩展名。 - 报错
ERR_REQUIRE_ESM:你在ESM里用require了。改成import或者用createRequire。 - 导入路径找不到:ESM要求相对路径带扩展名,检查是不是漏了
.js。 - 循环依赖报错:重构代码,打破循环。
__dirname未定义:用import.meta.url替代,参考前面的工具函数。- 测试失败:检查测试框架的ESM配置,特别是Jest需要额外标志。
- 第三方库不兼容:看库的文档,确认是否支持ESM,或者找替代品。
实战案例:一个中型项目的迁移过程
我最近帮一个团队迁移了一个有200多个模块的Node.js后端项目。他们的技术栈是TypeScript + Express,Node版本14,CJS为主。
第一步:升级Node到18,这是基础。 第二步:改造tsconfig,开启ESNext模块。 第三步:用脚本批量加扩展名,但只针对相对路径导入。 第四步:试点迁移一个API路由模块,发现循环依赖问题,重构后解决。 第五步:用tsup双输出,CJS和ESM共存一周,让团队适应。 第六步:全面切换,去掉CJS输出,更新依赖配置。 第七步:全量测试,修复了几个类型错误和路径问题。
整个过程花了两周,中间有几次小挫折,但整体平滑。团队反馈:ESM的Tree-shaking让Bundle小了30%,启动速度也快了不少。
最后的话
迁移CommonJS到ESM不是一蹴而就的事,但确实是值得做的。TypeScript生态正在往ESM靠拢,早迁移早受益。关键是做好规划,小步快跑,遇到问题逐一排查。希望这篇指南能帮你少走弯路。如果你在实际迁移中遇到具体问题,欢迎随时交流,咱们一起解决。
