airi × tsdown:cjsDefault 选项深度解析——掌控 CJS 输出的默认导出形态
2026/9/8 20:57:54 网站建设 项目流程

airi × tsdown:cjsDefault 选项深度解析——掌控 CJS 输出的默认导出形态

【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

tsdown 是基于 Rolldown 与 Oxc 构建的 TypeScript/JavaScript 库打包器,在 airi 仓库中作为约 29 个 packages 的标准构建工具(版本由 pnpm workspace 统一锁定在 pnpm-workspace.yaml 的catalog:中,为tsdown: ^0.22.14,各子包的package.json"build": "tsdown"脚本调用)。本文聚焦 tsdown 参考文档中的 cjsDefault 选项,完整讲解它如何控制 CommonJS 产物中默认导出的落地形态(module.exports还是exports.default)、两种取值下生成代码与声明文件的差异,以及如何结合仓库中真实的tsdown.config.ts配置判断自己是否需要显式设置该选项。读完本文,你可以为双格式(ESM + CJS)库精确设计require()消费方的接入体验,并理解 airi 各子包为何普遍选择纯 ESM 从而绕过这一问题。

一、cjsDefault 解决什么问题

当库同时构建出 ESM 与 CJS 两种产物时(format: ['esm', 'cjs']),CJS 产物中"默认导出"如何落地存在两种流派:

模式CJS 产物写法消费方 require 方式
cjsDefault: true(默认)module.exports = greetconst greet = require('your-module')
cjsDefault: falseexports.default = greetconst { default: greet } = require('your-module')

选项定义如下(摘自 option-cjs-default.md):

cjsDefault?: boolean // default: true

其生效条件有两个前提:一是产物格式为cjs(即 输出格式 中包含 CJS);二是入口模块只有单个 default 导出。满足这两点时,tsdown 会将默认导出直接挂到module.exports上,而不是保留 ESM 语义的exports.default命名空间形态。

二、配置写法:启用与禁用

两种取值的完整配置示例(与原文档一致,可直接复制到任意tsdown.config.ts):

启用(默认行为)

import { defineConfig } from 'tsdown' export default defineConfig({ entry: ['src/index.ts'], format: ['cjs'], cjsDefault: true, // default behavior })

禁用

import { defineConfig } from 'tsdown' export default defineConfig({ entry: ['src/index.ts'], format: ['cjs'], cjsDefault: false, })

需要注意的隐含前提:

  • cjsDefault只影响format包含cjs的构建目标;若配置中只有format: 'esm'(这是 airi 仓库的普遍做法,见第五节),该选项实际上不会被触发。
  • 选项作用于"仅含单一默认导出"的入口。若入口同时存在默认导出与命名导出,tsdown 不会将module.exports整体替换为默认值——这既是安全边界,也是文档建议"既有默认导出又有命名导出时显式禁用"的原因。

三、工作机制:源码、CJS 产物与声明文件的三方对照

cjsDefault: true(默认)时的转换

源码src/index.ts):

export default function greet() { console.log('Hello, world!') }

生成的 CJS 产物dist/index.cjs):

function greet() { console.log('Hello, world!') } module.exports = greet

生成的声明文件dist/index.d.cts):

declare function greet(): void export = greet

注意声明文件中的export =语法:这是 TypeScript 对 CJS "整模块即值"语义的正式表达,保证类型检查与运行期行为一致——require()拿到的就是函数本身,调用方无需解包.default

cjsDefault: false 时的转换

默认导出保留为exports.default属性:

// dist/index.cjs function greet() { console.log('Hello, world!') } exports.default = greet

此时 CJS 消费方必须写require('your-module').default才能拿到函数本体。

消费方视角的差异总结

场景cjsDefault: truecjsDefault: false
require()返回值函数本体模块命名空间对象
调用写法const greet = require('your-module'); greet()require('your-module').default()
声明文件export = greetexport default greet
与 ESMimport greet from的直觉一致(都直接拿到本体)一致(都需处理 default 包裹)

四、何时应该禁用 cjsDefault

原文档给出三条禁用场景,展开说明如下:

  1. 模块同时存在默认导出和命名导出:一旦module.exports被整体替换为默认值,命名导出将无法以常规方式附着,行为会变得不一致;显式cjsDefault: false可让所有导出统一走exports.*属性,CJS 消费方行为可预期。
  2. 需要一致的exports.default行为:多入口库中若部分入口是纯默认导出、部分不是,保持全部走exports.default可以让消费方使用统一的解包逻辑(例如mod.default ?? mod的垫片只写一处)。
  3. 消费方全部使用 ESMimport:当 CJS 产物只是兼容兜底、实际无人require()时,禁用cjsDefault反而能避免"看起来可直接require()调用"造成的误用。

配套的最佳实践(Tips,与原文档一致):

  1. 大多数库保持默认true即可;
  2. 同时存在默认与命名导出、且需要一致行为时禁用
  3. 发布前用真实的 CJS 消费方脚本测试产物兼容性(例如临时写一个require('dist/index.cjs').cjs探针文件运行验证),不要只靠 ESM 侧的类型检查推断 CJS 行为。

五、在 airi 仓库中的实践观察

airi 仓库是 tsdown 的大规模使用现场:通过 find tsdown.config.ts 可确认共有 29 个配置文件,分布在packages/integrations/plugins/services/server/packages/等处,构建命令统一为"build": "tsdown"(watch 场景如 integrations/vscode/vscode-airi/package.json 使用tsdown --watch)。

从源码结构看,这些子包对cjsDefault的实际依赖极低,原因在于它们的format选择:

  • 纯 ESM 显式声明:packages/plugin-sdk/tsdown.config.ts、integrations/vscode/airi-plugin-vscode/tsdown.config.ts、packages/electron-vueuse/tsdown.config.ts 等均写有format: 'esm'。ESM 产物中默认导出本来就是export default语义,cjsDefault不参与。
  • 未显式指定 format 的默认行为:packages/audio/tsdown.config.ts(多入口 +unbundle: true)、packages/better-ws/tsdown.config.ts、packages/stream-kit/tsdown.config.ts 未写format字段,按 输出格式文档 的说明,tsdown 默认格式为 ESM,同样不触发 CJS 默认导出转换。
  • 仅声明入口差异,格式一致:如 packages/cap-vite/tsdown.config.ts 使用对象式多入口(index/bin/run/vite-plugin/vite-wrapper-config)并设置target: 'node18',但同样未声明cjs格式。

可以推断:airi 作为以 ESM 为主的现代 workspace(pnpm-workspace.yaml中 TypeScript 已升至 6.x 目录),将 CJS 兼容负担整体交给了上游工具链而非库产物,因此没有任何子包需要为cjsDefault写显式配置。这正是原文档 Tips 第 1 条"大多数库保持默认即可"在真实 monorepo 中的印证——选项的默认值true在"不产出 CJS"的项目里甚至零成本

反过来,当你在 airi 风格的项目中为某个子包新增format: ['esm', 'cjs'](例如给旧版 Node.js 生态或 VS Code 扩展宿主提供require()入口)时,cjsDefault才是需要主动决策的选项:入口是纯默认导出且希望require()拿到本体 → 保持默认;入口混合了命名导出且希望 CJS/ESM 两侧行为对称 → 显式cjsDefault: false

六、与相邻选项的配合关系

cjsDefault不是孤立开关,它与以下两个选项共同决定双格式库的兼容面(对应原文档的 Related Options 章节):

  • 输出格式(formatcjsDefault仅在产物包含cjs时有意义。airi 各子包以format: 'esm'或省略(默认 ESM)为主,故该选项在这些配置中处于休眠状态;若按该文档的"Node.js 包(CJS + ESM)"模式补上format: ['esm', 'cjs']cjsDefault才进入生效范围。
  • Shims(shims:两者解决的是同一兼容问题的不同侧面——cjsDefault决定CJS 产物里默认导出如何暴露shims决定ESM/CJS 之间缺失的运行时变量__dirname__filenameimport.meta.*)如何补齐。构建双格式 Node.js 库时,典型组合是format: ['esm', 'cjs']+platform: 'node'+shims: true,再按第四节的判断决定是否追加cjsDefault: false

七、快速决策清单

综合原文档与 airi 仓库的实际配置,给出一条可执行的决策路径:

  1. 产物是否包含cjs格式?否 → 无需关心cjsDefault(airi 当前 29 个子包均落在这一侧);
  2. 是 → 入口是否只有单个默认导出?是且希望require()直接拿到本体 → 保持默认true
  3. 入口混合默认与命名导出、或消费方以 ESM 为主、CJS 仅作兜底 → 显式cjsDefault: false
  4. 无论哪种选择,用真实的 CJS 探针(require产物并调用)验证,再结合 shims 检查__dirname/import.meta等变量在双格式下的可用性。

更多 tsdown 选项(entrydtsdepsunbundleworkspace等)与 CLI 用法可继续参考 tsdown 技能索引。

【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi

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

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

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

立即咨询