- CLI
- 开发工具
- 语言运行时
【免费下载链接】tsx
⚡️ TypeScript Execute | The easiest way to run TypeScript in Node.js
导读:本文基于 tsx 仓库的决策文档 notes/tsx/cjs-default-import-interop.md,完整解析 tsx 如何对待一个module.exports同时带有__esModule与default属性的 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)的谓词都不是绝对安全的。真正安全的解包信号只有三种:
- loader 拥有的转换 provenance(即转换器自己知道"这份 CJS 输出是我从 ESM 转出来的");
- 显式的版本化 opt-in(用户主动声明某种互操作模式);
- 包通过
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值,D为M.default,tsx 的完整行为如下:
| 导入路径 | 结果 | 归属方 |
|---|---|---|
| 原生静态 ESM 默认导入 | M | Node |
| 被编译为 CommonJS 的静态导入 | 对标记过的编译器输出取D | esbuild |
原生import() | 命名空间,且.default === M | Node |
tsx 转换源码中的import() | 当命名空间默认值是含__esModule的对象时取M | tsx 兼容性转换 |
作用域化的api.import() | 原生命名空间 | Node |
tsx.require() | 原始require()值M | Node |
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.require、register的 CJS 上下文)使用transformSync()(src/utils/transform/index.ts),其 esbuild 选项为format: 'cjs'(index.ts),并附带platform: 'node'与 CJS banner/footer 包装。esbuild 的 CJS 输出对"被标记的编译器输出"(带__esModule的exports.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.exports | Node 兼容的运行时行为 |
| Babel | babel模式取.default;node模式取完整值 | 显式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)的动态导入解包。但在改动该路径之前,必须为以下场景补齐行为测试:
- 带
__esModule与default的故意的CommonJS 对象; - 继承的、值为
false的、不可枚举的__esModule属性; - 编译器产出的 CJS 默认导出与命名导出;
- ESM 与 CommonJS 导入方下的静态/动态导入对等性;
- ESM 命名空间包装、循环依赖与缓存身份。
这些测试项直接对应 3.3 节谓词的三个"比惯例更宽"的细节(in检查、不要求true、对象形状判定),是未来任何语义收窄的回归防线。
六、实践建议:包作者如何主动避开歧义
结合上述决策,包作者可以通过三种途径主动消除歧义,而不是把命运交给调用方的执行模型:
- 提供
exports.import条件入口,让导入方始终命中原生 ESM——这是决策文档点名的"最安全信号"之一; - 避免同时暴露
__esModule与default的双重形态:手写 CJS 包若把完整对象作为公开 API,就不要给module.exports打__esModule标记,防止被 esbuild/编译器按"标记过的编译器输出"解包; - 理解并利用 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
相关推荐
深入解析 Bun 的 CommonJS 默认导出互操作策略:`__esModule` 解包规则及其与 tsx / Node 的分歧
深入解析 Bun 的 CommonJS 默认导出互操作策略: __esModule 解包规则及其与 tsx / Node 的分歧 本篇技术指南围绕 tsx 仓库
CLI开发工具语言运行时TypeScript 的 CommonJS/ESM 默认导入互操作:`__esModule` 约定、Node 感知 ESM 输出与 tsx 的实际落地
TypeScript 的 CommonJS/ESM 默认导入互操作: __esModule 约定、Node 感知 ESM 输出与 tsx 的实际落地 本文以仓库
CLI开发工具语言运行时Rolldown 的 CommonJS 打包实战指南:原生 CJS 支持、ESM 互操作与边界行为
Rolldown 的 CommonJS 打包实战指南:原生 CJS 支持、ESM 互操作与边界行为 Rolldown(Rust 编写的 JavaScript/T
构建工具前端构建开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考