在 typescript 项目启用 esm("type": "module" + "module": "esnext")后,.ts 文件间导入需显式指定扩展名,但启用 allowimportingtsextensions 会引发额外问题;本文提供符合 node.js esm 规范、无需写 .ts 后缀的标准化解决方案。
在 typescript 项目启用 esm("type": "module" + "module": "esnext")后,.ts 文件间导入需显式指定扩展名,但启用 allowimportingtsextensions 会引发额外问题;本文提供符合 node.js esm 规范、无需写 .ts 后缀的标准化解决方案。
当项目从 CommonJS 迁移至原生 ESM(通过设置 "type": "module" 和 tsc 的 "module": "esnext"),TypeScript 编译器与 Node.js 运行时对模块解析规则产生关键分歧:Node.js ESM 要求导入路径必须带有明确的文件扩展名(如 .js、.mjs、.cjs),而 .ts 是源码扩展名,不属于运行时可解析的格式。
因此,以下写法在 ESM 下必然失败:
// ❌ 错误:Node.js ESMLoader 无法解析无扩展名的路径
import { Button } from "../../../objects/Button";即使该路径对应 Button.ts,Node.js 也不会自动尝试 .ts 后缀——它只遵循 ESM 规范查找 .js/.mjs 等已编译产物。
你遇到的两个错误正体现了这一矛盾:
- 不加扩展名 → Cannot find module(Node.js 找不到对应 .js 文件);
- 加 .ts 扩展名 → 报错提示 allowImportingTsExtensions 未启用(因该选项仅用于开发期类型检查,不改变运行时行为,且启用后会导致构建和兼容性问题,官方明确不推荐用于生产)。
✅ 正确解法是:让 TypeScript 输出标准 ESM 兼容的 .js 文件,并在 import 中使用 .js 扩展名(而非 .ts)。这是 Node.js 官方推荐、Vite/Next.js/SvelteKit 等现代工具链一致采用的模式。
步骤一:配置 TypeScript 输出 .js + 声明文件
确保 tsconfig.json 启用 declaration: true 和 outDir,并移除或禁用 allowImportingTsExtensions(它本就不该开启):
{
"compilerOptions": {
"module": "esnext",
"target": "es2020",
"moduleResolution": "bundler", // 推荐:匹配现代打包器逻辑
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"allowImportingTsExtensions": false, // 显式禁用,避免误导
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"strict": true
}
}步骤二:导入时使用 .js 扩展名(源码中)
修改所有跨文件导入,将 .ts 替换为 .js:
// ✅ 正确:指向编译后的 .js 文件,符合 ESM 规范
import { Button } from "../../../objects/Button.js";⚠️ 注意:这是源码中的书写方式,不是物理重命名文件。TypeScript 编译后生成 Button.js,Node.js 直接加载它。
步骤三:确保输出结构与导入路径对齐
例如,若源码路径为 src/objects/Button.ts,经 tsc 编译后应输出为 dist/objects/Button.js。此时 import ".../Button.js" 才能被 Node.js 正确解析。
补充建议
- 使用 @types/node 确保 ESM 类型支持;
- 若使用构建工具(如 Vite、esbuild),它们通常自动处理 .ts → .js 映射,无需手动写 .js 扩展名(但底层原理相同);
- 永远不要在生产环境启用 allowImportingTsExtensions:它绕过标准模块解析,破坏工具链兼容性,且无法在纯 JS 运行时工作。
总结:ESM 的本质是运行时规范,TypeScript 是构建时工具。解决问题的关键不是让运行时迁就源码,而是让源码导入语句准确指向运行时存在的产物——即 .js 文件。这既是标准,也是可维护性的基石。









