说实话,把 TypeScript 项目从 CommonJS 迁移到 ESM(ECMAScript Modules)的过程,简直就是一场“大型祛魅”现场。你以为改了个 module: "ESNext" 就能高枕无忧?Too young, too naive。我在帮一家电商团队重构核心模块时,亲眼看着他们在一夜之间从“代码能跑”变成“满屏红色波浪线”,服务器启动失败,测试集体崩溃,而罪魁祸首竟然只是一个 tsconfig.json 里的配置顺序问题。
这篇文章不玩虚的,直接把你从 CommonJS 转向 ESM 路上可能踩的所有坑,连同解决方案,一次性给你讲透。咱们把那些让编译器报错、让运行时崩溃、让你怀疑人生的问题,一个个掰开揉碎。
第一阶段:你以为是配置问题,其实是“双标”的 Node.js
首先,咱们得理解一个核心矛盾:Node.js 对 CJS 和 ESM 的解析规则是完全不同的两套逻辑。TypeScript 编译器(tsc)只负责把 TS 转成 JS,至于这个 JS 在 Node 里能不能跑,那是 Node 说了算。
很多开发者遇到的第一个坑是:tsconfig.json 配得完美无缺,tsc 编译零报错,但一运行 node dist/index.js,直接抛错 ERR_REQUIRE_ESM 或者 Cannot find module。
这就好比你在学校里考试满分,但社会招聘的时候因为学历不符被刷下来一样。TypeScript 编译器是“学校老师”,它只关心你的语法和类型对不对;Node.js 是“HR”,它关心的是这个文件到底是不是合法的 ESM。
坑点一:package.json 里忘了加 "type": "module"
这是最经典、也最容易被忽视的一步。在 CommonJS 世界里,Node.js 默认把所有 .js 文件当成 CJS 处理。但在 ESM 模式下,你必须明确告诉 Node.js:“嘿,我这个项目全是 ESM!”
错误示范:
{
"name": "my-esm-project",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
这时候你跑 node dist/index.js,Node 会以为它是 CJS,结果里面用了 import 语法,直接报错:SyntaxError: Cannot use import statement outside a module。
正确做法:
在 package.json 根部加上 "type": "module"。
{
"name": "my-esm-project",
"version": "1.0.0",
"type": "module", // 关键!
"main": "dist/index.js",
...
}
加了这一行,Node.js 才会把同目录下的所有 .js 文件当作 ESM 处理。
坑点二:tsconfig.json 的 module 和 moduleResolution 不匹配
TypeScript 的配置有两个关键参数:module(输出什么格式的模块)和 moduleResolution(怎么查找模块)。这两个必须协调,否则就会出现“编译能通过,运行找不到模块”的情况。
常见的错误组合:
module: "ESNext"+moduleResolution: "node"module: "CommonJS"+moduleResolution: "nodeNext"
正确的组合指南:
| 目标环境 | module |
moduleResolution |
target |
适用场景 |
|---|---|---|---|---|
| 现代 Node.js (>=14) | "NodeNext" |
"NodeNext" |
"ES2020" |
推荐!最严格,兼容性最好 |
| 浏览器/现代前端 | "ESNext" |
"bundler" 或 "node" |
"ES2020" |
Webpack/Vite 项目 |
| 老旧 Node.js | "CommonJS" |
"node" |
"ES2017" |
遗留系统维护 |
专家建议: 如果你是在 Node.js 环境下开发后端服务,请无脑使用 "module": "NodeNext" 和 "moduleResolution": "NodeNext"。这是 TypeScript 官方推荐的、最贴近 Node.js 原生行为的配置。
第二阶段:路径解析失败的根源——“相对路径”是唯一的真理
在 CommonJS 时代,我们经常用 tsconfig.json 里的 paths 映射,或者直接用 @/utils/helper 这种别名来导入模块。这在 Webpack 时代很爽,但在纯 ESM 环境下,这些“捷径”很多都走不通了。
坑点三:paths 映射在 Node.js 原生 ESM 中失效
假设你的 tsconfig.json 是这样配的:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@utils/*": ["src/utils/*"]
}
}
}
你在代码里写 import { foo } from '@utils/helper',TypeScript 编译器(tsc)在编译时能看到这个映射,所以不报错。但是,tsc 输出的 JS 文件里,这个 import 语句会被原样保留,或者变成相对路径,具体取决于你的配置。
更重要的是,Node.js 在运行时根本看不懂 @utils/helper 这个路径。它不知道 @utils 映射到哪,于是抛出:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@utils'
解决方案:
彻底放弃
paths映射,改用相对路径。 这是最稳妥、最符合 ESM 规范的做法。虽然写起来麻烦一点,比如从src/utils/helper变成../../utils/helper,但它保证了 TypeScript 和 Node.js 的理解完全一致。如果你非要保留别名,使用
tsconfig-paths注入。 在package.json的start脚本里,用ts-node或tsx配合tsconfig-paths来运行,而不是直接node dist/index.js。npx ts-node --project tsconfig.json -r tsconfig-paths/register src/index.ts注意,这种方式通常用于开发阶段的调试,生产环境还是建议编译后使用相对路径。
坑点四:动态导入 import() 中的路径必须是字符串字面量
ESM 规范要求,import() 动态导入的路径必须是编译时可确定的字符串字面量,不能是变量拼接。
错误示范:
const moduleName = 'utils';
// 编译时可能不报错,但运行时可能出问题,或者被打包工具错误处理
import(`./${moduleName}/index`)
.then(mod => mod.default())
.catch(err => console.error(err));
正确示范:
// 明确写出所有可能的路径
const modules = {
utils: () => import('./utils/index'),
services: () => import('./services/index'),
};
// 使用时
modules.utils().then(mod => mod.default());
或者,如果你确实需要动态路径,确保使用 import() 时路径是常量字符串,而不是变量。
第三阶段:编译报错的深层原因——“顶层 await”和“文件扩展名”
坑点五:topLevelAwait 需要显式开启
ESM 支持在模块顶层直接使用 await,这在 CJS 中是不允许的(CJS 里必须用 .then() 或 IIFE)。TypeScript 在默认配置下,如果使用 target: "ES2020" 或更高版本,并且 module: "NodeNext",可能会报错:Top-level await is not available in the target。
解决方案:
在 tsconfig.json 中开启 esModuleInterop 和正确的 target。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
开启后,你就可以在 index.ts 里直接写:
const data = await fetch('/api/data');
console.log(data);
这在 ESM 环境下是完全合法的,而且在启动时就能确保异步数据加载完成。
坑点六:导入 .js 文件时必须写扩展名
这是 ESM 规范的一个硬性要求:所有导入的文件路径必须包含扩展名,即使是 .js 文件。TypeScript 编译器在 moduleResolution: "node" 模式下,可以自动推断 .js 扩展名,但在 moduleResolution: "NodeNext" 模式下,它会严格遵循 Node.js 的行为,要求你显式写出扩展名。
错误示范:
import { helper } from './utils/helper'; // 缺少 .js
这会报错:Module not found: Cannot resolve module
正确示范:
import { helper } from './utils/helper.js'; // 必须加 .js
即使你的源文件是 helper.ts,在导入时也要写 helper.js。因为 tsc 会把 helper.ts 编译成 helper.js,Node.js 运行时加载的是 .js 文件。
为什么这么设计? 这是为了让模块依赖关系在编译后依然清晰可见,避免 Node.js 在运行时进行复杂的文件搜索,从而提高性能和确定性。
第四阶段:第三方库的兼容性问题——“它们还是 CJS!”
这是最让人头疼的部分。你把自己的代码改成了 ESM,但你的依赖库(比如 lodash、moment、甚至是某些老版本的 express 中间件)还是 CommonJS。
坑点七:混合导入报错 require is not defined
当你从 ESM 模块中 import 一个 CJS 库时,TypeScript 编译后会生成 import xxx from 'library'。在 Node.js 的 ESM 环境中,如果这个库没有提供 ESM 导出,你可能会遇到兼容性问题。
解决方案:
- 检查库是否支持 ESM。 很多现代库(如 lodash-es、axios)都提供了 ESM 版本。优先使用这些。
- 使用
createRequire模拟 CJS 导入。
注意:import { createRequire } from 'module'; const require = createRequire(import.meta.url); // 现在你可以像 CJS 一样 require 了 const moment = require('moment');import.meta.url是 ESM 中获取当前文件 URL 的方式,在 CJS 中对应的是__dirname和__filename。
坑点八:resolveJsonModule 与 ESM 的冲突
在 CJS 中,我们常直接 import config from './config.json'。在 ESM 中,如果 tsconfig.json 没有开启 resolveJsonModule,会报错。即使开启了,如果 Node.js 版本较低(<14.17),也可能无法直接导入 JSON 文件。
解决方案:
- 确保
tsconfig.json中开启resolveJsonModule: true。 - 对于 Node.js 14.17+,可以直接导入 JSON,但需要加
.json扩展名,并且使用命名导入或动态导入。import config from './config.json' assert { type: 'json' }; // Node.js 17+ // 或者 const config = await import('./config.json', { assert: { type: 'json' } });
第五阶段:实战——一个完整的、可运行的 ESM TypeScript 项目配置
光说不练假把式。下面我给出一个经过实战检验的、能解决上述大部分问题的 tsconfig.json 配置模板,以及对应的 package.json 配置。
1. tsconfig.json 终极配置
{
"compilerOptions": {
/* 基础设置 */
"target": "ES2022", // 目标 ES 版本,支持顶层 await
"module": "NodeNext", // 输出 Node.js 原生 ESM 格式
"moduleResolution": "NodeNext", // 使用 Node.js 的 ESM 解析规则
"lib": ["ES2022"], // 包含 ES2022 的库定义
"outDir": "./dist", // 输出目录
"rootDir": "./src", // 源码根目录
"strict": true, // 开启所有严格类型检查
/* 模块相关 */
"esModuleInterop": true, // 兼容 CJS 默认导入
"allowSyntheticDefaultImports": true, // 允许非默认导入
"resolveJsonModule": true, // 允许导入 JSON 文件
"forceConsistentCasingInFileNames": true, // 强制文件名大小写一致
/* 其他 */
"skipLibCheck": true, // 跳过 node_modules 中的类型检查,提升编译速度
"declaration": true, // 生成 .d.ts 声明文件
"sourceMap": true // 生成 source map,方便调试
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
2. package.json 关键配置
{
"name": "esm-typescript-demo",
"version": "1.0.0",
"type": "module", // 关键:声明项目为 ESM
"main": "dist/index.js", // 注意:这里指向编译后的 .js 文件
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts" // 开发时用 tsx 直接运行 TS,支持热更新
},
"devDependencies": {
"typescript": "^5.3.0",
"tsx": "^4.7.0", // 现代化的 TS 执行器,比 ts-node 更快
"@types/node": "^20.10.0"
}
}
3. 源码示例 src/index.ts
// 导入相对路径必须带 .js 扩展名
import { hello } from './utils/helper.js';
import config from './config.json' assert { type: 'json' };
// 使用顶层 await
const startTime = Date.now();
async function main() {
console.log(`App started at ${config.appName}`);
console.log(hello('World'));
// 模拟异步操作
const data = await fetchData();
console.log('Fetched data:', data);
const duration = Date.now() - startTime;
console.log(`Execution time: ${duration}ms`);
}
async function fetchData(): Promise<string> {
// 这里可以调用真实的 API
return 'Mocked data from ESM module';
}
main().catch(console.error);
4. src/utils/helper.ts
export function hello(name: string): string {
return `Hello, ${name}! This is ESM module.`;
}
5. src/config.json
{
"appName": "My ESM TypeScript App",
"version": "1.0.0"
}
第六阶段:调试技巧与常见错误排查清单
当你遇到报错时,不要慌。按照以下清单逐步排查,90% 的问题都能解决。
排查清单
- 确认
package.json有"type": "module"。 这是第一步,也是最容易被遗忘的一步。 - 检查
tsconfig.json的module和moduleResolution是否都是"NodeNext"。 如果不是,改成NodeNext。 - 所有导入路径是否都加了
.js扩展名? 特别是从src目录内部导入时。 - 运行时是否使用了
node dist/...而不是tsx src/...? 生产环境用node dist,开发环境用tsx。 - 第三方库是否兼容 ESM? 查看库的
package.json,看是否有"exports"字段或.mjs文件。如果没有,考虑使用createRequire或寻找 ESM 替代品。 - JSON 导入是否加了
assert { type: 'json' }? Node.js 17+ 要求显式声明导入类型。
常见错误对照表
| 错误信息 | 可能原因 | 解决方案 |
|---|---|---|
SyntaxError: Cannot use import statement outside a module |
package.json 缺少 "type": "module" |
添加 "type": "module" |
ERR_REQUIRE_ESM |
试图用 require() 加载 ESM 模块 |
改用 import() 或动态导入 |
Module not found: Can't resolve '@utils/helper' |
使用了 paths 映射,但 Node.js 无法解析 |
改用相对路径,或配置 tsconfig-paths |
Cannot find module './utils/helper' |
导入时缺少 .js 扩展名 |
添加 .js 扩展名:./utils/helper.js |
Top-level await is not available |
target 版本过低或 module 配置不当 |
设置 "target": "ES2022" 和 "module": "NodeNext" |
Unexpected token 'export' |
编译后的 JS 仍是 CJS 格式 | 检查 tsconfig.json 的 module 是否为 "NodeNext" 或 "ESNext" |
