V2EX 10月19日 03:44
TypeScript 模块导入演进与 ESM 兼容性挑战
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文探讨了 TypeScript 中模块导入方式的演变,特别是在使用 ES Modules (ESM) 时遇到的兼容性问题。从早期 `import` 不带扩展名引发的 Node.js 运行时错误,到引入 `.js` 扩展名以适应 `nodenext` 模式,再到 Node.js 直接支持 `.ts` 文件执行带来的新挑战,每一步都伴随着 TypeScript 配置和开发实践的调整。文章重点分析了 `allowImportingTsExtensions` 选项的出现及其对 `noEmit` 的限制,并抛出了在不同场景下(如本地测试与生产部署)如何选择模块导入策略的疑问。

📦 **ESM 模块导入的早期挑战:** 在 Node.js 环境下,早期 TypeScript 编译的 ESM 代码,如果 `import` 语句不包含文件扩展名(如 `.js`),在运行时会因 `ERR_UNSUPPORTED_DIR_IMPORT` 错误而失败。这是因为 Node.js 在处理 ESM 时需要明确的文件路径。

💡 **引入 `.js` 扩展名:** 为了解决上述问题,一种常见的做法是在 `tsconfig.json` 中配置 `module=nodenext` 和 `moduleResolution=nodenext`,并在 `import` 语句中显式添加 `.js` 扩展名。尽管这种写法在 `.ts` 文件中显得不够优雅,但它能确保编译后的 JavaScript 代码在 Node.js 中正常运行。

🚀 **Node.js 直接执行 `.ts` 带来的新变化:** 随着 Node.js 开始支持直接执行 TypeScript 文件(丢弃类型信息),以及 `allowImportingTsExtensions` 选项的出现,开发者开始尝试在 `import` 语句中直接使用 `.ts` 扩展名。这似乎是顺应了 Bun 和 Deno 等竞争者的生态,也为本地开发带来了便利。

🤔 **`allowImportingTsExtensions` 的权衡:** 启用 `allowImportingTsExtensions` 选项允许在 `.ts` 文件中使用 `.ts` 导入,但同时也强制要求启用 `noEmit`。这意味着,如果选择这种方式,就不能使用 `tsc` 将 TypeScript 代码编译为 JavaScript。这在本地测试直接运行 `.ts` 文件与生产环境编译为 `.js` 运行之间,构成了一个重要的权衡点,开发者需要在两者之间做出选择。

假设在 ./utils/calcute.ts 中有一个工具函数 add()

export function add(a: number, b: number): number {  return a + b;}

然后我们在 main.ts 中需要使用这个 add 函数

写法 1, import 不带扩展名:

tsconfig 配置 module=esnext ,然后假设有如下 main.ts 文件

import { add } from "./utils/calcute";add(1,2)

使用 tsc 编译后使用 node 运行编译后的 js 文件会报错

node ./dist/main.js... 省略  code: 'ERR_UNSUPPORTED_DIR_IMPORT',  url: 'file:///home/xxxxxx/dist/utils/calcute' 

原因是现在的 node 处理 esm 的 import 需要指定具体文件名(即类似 import ./utils/calcute.js )。不写扩展名的 import 会报错

而 typescript 编译代码对 import 内 from "xxxx" 的部分是不会做任何处理直接保留的。按照 ts 官方的意思就是这部分是模块解析,不应该是 typescript 的工作而应交给 js 运行时(如 node 、浏览器)自己处理,所以 tsc 编译 ts 文件是会完整保留这部分不做任何变动的

基于这种方针,于是就有了两种解法

    放弃 tsc 编译使用 bundle下面的写法 2

写法 2:import .js

tsconfig 配置 module=nodenext 和 moduleResolution=nodenext ,然后 main.ts 内容如下

import { add } from "./utils/calcute.js"; // 需要添加 .js 扩展名add(1,2)

说真的,当年我接触到这种写法的时候是大受震撼的。 在 ts 文件中写 import .js 实在过于丑陋了。我不解、我不适应、我无法接受

但这样的代码经过 tsc 编译后就能正常被 node 执行了,我也只能捏着鼻子用了

本来以为 esm 的问题也就这样了,但没想到到了 2025 年就乱套了

写法 3: import .ts

因为 bun, deno 的竞争,不思进取的 node 终于开始迭代起功能了。甚至还破天荒地添加了直接执行 typescript 代码的功能(运行的时候直接丢弃类型信息把 ts 当 js 跑)

这个功能现在在在新 node 中已经默认开启可用了,并且 typescript 也为了这个功能添加多个更新。所以可以预见今后用 node 直接执行 ts 会多起来

然后,这个功能在 esm 上就不出意外得出意外了。还是上面的代码 main.ts 内容如下:

import { add } from "./utils/calcute.js"; // 需要添加 .js 扩展名add(1,2)

使用 node main.ts 执行后直接报错

node main.ts... 省略  code: 'ERR_MODULE_NOT_FOUND',  url: 'file:///home/xxxxxxxx/utils/calcute.js'

嗯,因为模块的代码位于文件 utils/calcute.ts 中,而 import 语句中写的是 ./utils/calcute.js,所以 node 理所当然的找不到对应的模块文件报错了

所以为了解决这个问题,tsconfig 后来添加了一个选项 allowImportingTsExtensions ,开启后在 main.ts 中需要将 import 改写成 import .ts 的形式

import { add } from "./utils/calcute.ts"; // 需要 import .ts ,而不是.jsadd(1,2)

嗯,当年 typescript 的回旋镖就这么砸了回来,现在我们又必须在 ts 文件中写 import .ts 了。并且为了兼容这种写法 typesript 现在还不得不添加新的编译选项 allowImportingTsExtensions 来允许在 ts 文件中 import .ts

但是,这有个问题,启用这个选项必须也启用 noEmit ,也就是说在 typescript 官方那的说法是:我们没有被打脸啊,我们依旧不处理 import 的内容,你想 import .ts 可以,但是你这样写了的话就别用我们的 tsc 来把这种代码编译成 js 了

但问题是实际上开发中,使用 node 直接执行 ts 文件测试,然后在生产环境中使用 tsc 或其他工具编译成 js 运行会很常见

于是如果你想直接 node 执行 ts 代码,那就得放弃将使用 tsc 将代码编译为 js

所以大家怎么选

目前这 esm import 写法已经乱成这样了,大家平时会怎么选?

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

TypeScript ESM 模块导入 Node.js tsconfig 兼容性 编译 开发实践 TypeScript module import ES Module Node.js compatibility tsconfig.json compilation developer workflow
相关文章