tsx 的 CommonJS 默认导入互操作:`__esModule` 歧义、行为对照与兼容边界
2026/9/23 13:55:58 网站建设 项目流程
  • CLI
  • 开发工具
  • 语言运行时

【免费下载链接】tsx

⚡️ TypeScript Execute | The easiest way to run TypeScript in Node.js

项目地址:https://gitcode.com/gh_mirrors/ts/tsx
点击查看免费下载

导读:本文基于 tsx 仓库的决策文档 notes/tsx/cjs-default-import-interop.md,完整解析 tsx 如何对待一个module.exports同时带有__esModuledefault属性的 CommonJS 包。你将理解"默认导入到底拿到整个module.exports还是它的.default"这一歧义的根源、tsx 在六种导入路径下的具体语义(含源码级依据),以及未来改动必须守住的兼容边界——无论你是包作者、tsx 用户还是想在工具链中复刻该语义的开发者,本文都能给出可直接引用的决策依据。

一、问题背景:为什么__esModule+default是一个无法消除的歧义

CommonJS 世界里,有两种完全不同的模块可以暴露同一个运行时值

// 转译出来的 ESM(如 TypeScript/Babel 产物) Object.defineProperty(exports, '__esModule', { value: true }) exports.default = handler // 手写的 CommonJS API Object.defineProperty(module.exports, '__esModule', { value: true }) module.exports.default = handler

第一类模块通常期望Babel 风格的互操作(默认导入被绑定到.default);第二类模块则可能故意把完整对象作为公开 API 暴露(此时解包.default反而是错误)。

问题的关键在于:从值的键(keys)、属性描述符(descriptors)、源码形态(source pattern)、包元数据(package metadata)到声明文件(declaration file),运行时都无法区分这两类模块;TypeScript 也将这两种输出判定为结构上完全相同

因此可以得出一个强结论:任何只基于对象形状(shape)的谓词都不是绝对安全的。真正安全的解包信号只有三种:

  1. loader 拥有的转换 provenance(即转换器自己知道"这份 CJS 输出是我从 ESM 转出来的");
  2. 显式的版本化 opt-in(用户主动声明某种互操作模式);
  3. 包通过exports.import入口选择原生 ESM,从而在加载路径上绕开歧义。

二、tsx 的决策:按"导入方执行模型"而非"包作者意图"决定语义

tsx 的立场可以概括为一句话:

原生 ESM 中,tsx 完全跟随 Node:从 CommonJS 的默认导入就是完整的module.exports值;tsx 不会自动把__esModule重新解释为"把导入直接绑定到.default"。

而当 tsx 把一个导入方文件编译为 CommonJS 时,esbuild 会应用其 Babel 兼容的 interop helper(对标记过的编译器输出解包到.default)。

这个区分的核心原则是:语义由导入方的执行模型决定,而不是去猜测包作者的意图。同一份被导入的包,在原生 ESM 环境下和在被 tsx 编译为 CJS 的环境下,得到的结果可以不同——这正是下面行为对照表的由来。

三、当前行为对照:六种导入路径,六种明确语义

M为 CommonJS 的module.exports值,DM.default,tsx 的完整行为如下:

导入路径结果归属方
原生静态 ESM 默认导入MNode
被编译为 CommonJS 的静态导入对标记过的编译器输出取Desbuild
原生import()命名空间,且.default === MNode
tsx 转换源码中的import()当命名空间默认值是含__esModule的对象时取Mtsx 兼容性转换
作用域化的api.import()原生命名空间Node
tsx.require()原始require()MNode

3.1 原生静态 ESM:保持 Node 语义

ESM hook 在load阶段对 TypeScript/ESM 源调用transform()(src/utils/transform/index.ts),其中 esbuild 选项固定为format: 'esm'(index.ts),产物以format: 'module'返回给 Node(src/esm/hook/load.ts)。由于产物保留 ESM 形态,静态绑定的默认导入完全走 Node 自身的 CJS 互操作,即拿到完整的M

3.2 被编译为 CommonJS 的静态导入:esbuild 的 Babel 风格互操作

CJS 加载路径(如tsx.requireregister的 CJS 上下文)使用transformSync()(src/utils/transform/index.ts),其 esbuild 选项为format: 'cjs'(index.ts),并附带platform: 'node'与 CJS banner/footer 包装。esbuild 的 CJS 输出对"被标记的编译器输出"(带__esModuleexports.default形态)生成 Babel 兼容的互操作代码,因此默认导入得到D

从源码结构看,同一份源码在两条路径(ESM hook vs CJS loader)下走了两套不同的 esbuild 配置,这正是"语义跟随导入方执行模型"的实现载体:导入方是 ESM 则保留 ESM,导入方是 CJS 则按 esbuild 惯例互操作。

3.3 tsx 转换源码中的动态导入:唯一的解包例外

这是 tsx 特有的"legacy 兼容行为",实现在 src/utils/transform/transform-dynamic-import.ts。tsx 会把源码中的import()用 MagicString 追加.then(...)处理(transform-dynamic-import.ts),其核心谓词逻辑为:

const toEsmFunctionString = (imported => { const d = 'default'; if ( imported[d] && typeof imported[d] === 'object' && '__esModule' in imported[d] ) { return imported[d]; } return imported; }).toString();

需要特别指出该谓词的三个细节,它们都比常见的编译器惯例更宽

  • 它解包的目标是命名空间默认值imported.default),把它塌缩为M,而不是直接塌缩到D——即"把命名空间还原成模块本体";
  • 它通过in操作符检查__esModule,因此接受继承来的属性
  • 不要求__esModule === true,只要该属性存在且默认值为对象即可触发。

正因为这个谓词比惯例更宽、属于历史遗留的兼容行为,决策文档明确划出红线:它绝不能成为原生静态 ESM 导入的政策

3.4 作用域化api.import():回到原生命名空间

createScopedImport()(src/esm/api/scoped-import.ts)本质上是用tsx://包装 specifier 后调用原生import(),因此其返回的是 Node 原生命名空间语义,即.default === M,不受 tsx 动态导入转换影响。

3.5tsx.require():原始 CJS 值

tsxRequire直接暴露 Node 的require(src/cjs/api/require.ts),不做任何解包,结果始终是原始M

3.6 测试佐证

测试夹具 tests/fixtures.ts 中的cjs/index.cjs明确断言"__esModule被解包":

// Assert __esModule is unwrapped import ('../ts/index.ts').then((m) => assert( !(typeof m.default === 'object' && ('default' in m.default)), ));

同文件的mjs/index.mjs(tests/fixtures.ts)对真实 CJS 包pkg-commonjs做同样断言:动态导入后,命名空间默认值不应再是"带default属性的对象"。这两条测试恰好锁定了 3.3 节所述"动态导入解包到M"的行为。

四、其他工具的立场对照:大多数工具刻意"分裂"

把视野拉到整个工具链,会发现 tsx 的决策并非孤例,而是一条普遍规律:

工具对标记过的 CommonJS 默认导入选择器
Node 决策笔记完整module.exports对 CommonJS 目标始终如此
TypeScript 决策笔记CJS 产物中取.default;ESM 产物中取完整值导入方输出格式
esbuild 决策笔记Babel 模式下取.default;Node 模式下取完整值导入方 ESM 分类
Bun 决策笔记通常取.default;在type: module的导入方作用域内取完整值被导入方包作用域
Deno完整module.exportsNode 兼容的运行时行为
Babelbabel模式取.defaultnode模式取完整值显式importInterop选项
Rollup插件auto模式下对标记模块取.default插件或输出 interop 选项
Vite/Rolldown对 Node 分类的导入方取完整值;否则取.default导入方模块分类
webpack严格 ESM 取完整值;通过兼容 helper 取.default导入方模块分类
ts-node跟随 TypeScript CJS 产物或原生 Node ESM所选模块模式

这张对照表揭示的规律是:编译器兼容 helper 可以信任__esModule(针对被转换的导入方),而原生 ESM 运行时保留完整的 CommonJS 值。tsx 的两条腿——esbuild CJS 互操作 + Node 原生 ESM 语义——恰好横跨这两端;Bun 则是其中实质性的运行时例外(默认取.default)。

五、未来变更的边界:哪些事不能做,哪些事需要先补测试

决策文档为后续演进划定了两条清晰边界:

5.1 静态导入的红线

不要给原生静态 ESM 导入添加自动解包。一旦添加,tsx 将偏离 Node 语义,并破坏那些故意暴露完整对象的合法 CommonJS API。

若未来确实需要一个显式的 opt-in 兼容模式来定义替代语义,静态导入支持将要求导入方重写(importer rewriting)或合成 facade 模块,且必须完整保持:

  • 活绑定(live bindings);
  • 再导出(re-exports);
  • 循环依赖(cycles);
  • 混合命名/默认导入;
  • 缓存身份(cache identity);
  • 源码映射(source maps);
  • 同步/异步 hook 的对等性(sync/async hook parity)。

5.2 动态导入解包:可收窄,但必须先补测试

单独的 breaking release 可以评估移除或收窄对 ESM 分类源(ESM-classified sources)的动态导入解包。但在改动该路径之前,必须为以下场景补齐行为测试:

  • __esModuledefault故意的CommonJS 对象;
  • 继承的、值为false的、不可枚举的__esModule属性;
  • 编译器产出的 CJS 默认导出与命名导出;
  • ESM 与 CommonJS 导入方下的静态/动态导入对等性;
  • ESM 命名空间包装、循环依赖与缓存身份。

这些测试项直接对应 3.3 节谓词的三个"比惯例更宽"的细节(in检查、不要求true、对象形状判定),是未来任何语义收窄的回归防线。

六、实践建议:包作者如何主动避开歧义

结合上述决策,包作者可以通过三种途径主动消除歧义,而不是把命运交给调用方的执行模型:

  1. 提供exports.import条件入口,让导入方始终命中原生 ESM——这是决策文档点名的"最安全信号"之一;
  2. 避免同时暴露__esModuledefault的双重形态:手写 CJS 包若把完整对象作为公开 API,就不要给module.exports__esModule标记,防止被 esbuild/编译器按"标记过的编译器输出"解包;
  3. 理解并利用 tsx 的分裂语义:在 tsx 中被编译为 CJS 的导入方会得到.default(esbuild 惯例),原生 ESM 导入方与tsx.require()会得到完整M(Node 惯例)——据此设计测试,而不是假设"所有地方行为一致"。

延伸阅读

  • 本决策笔记所属的 tsx 研究索引:notes/tsx/README.md(另含模块解析、Node 集成、转换后端三条研究线)
  • 各工具立场详见仓库内决策笔记:notes/node/cjs-esm-interop.md、notes/typescript/cjs-esm-interop.md、notes/esbuild/cjs-esm-interop.md、notes/bun/cjs-esm-interop.md
  • 动态导入兼容转换的完整实现:src/utils/transform/transform-dynamic-import.ts
  • ESM/CJS 双路径转换实现:src/utils/transform/index.ts、src/esm/hook/load.ts
  • 行为断言测试夹具:tests/fixtures.ts
  • CLI
  • 开发工具
  • 语言运行时

【免费下载链接】tsx

⚡️ TypeScript Execute | The easiest way to run TypeScript in Node.js

项目地址:https://gitcode.com/gh_mirrors/ts/tsx
点击查看免费下载

相关推荐

上一篇:ThriveX-Blog自动化部署:零代码实现CI/CD流水线的完整指南
下一篇:UnityDataTools架构解析:三层架构如何实现高效Unity数据读取

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询