Project AIRI 中的 tsdown unbundle 模式实战:如何保留源码目录结构构建 monorepo 库
导读
在构建组件库、多语言包这类"由多个可独立导入模块组成"的 npm 包时,把全部源码打包成一个单文件往往并不是最优解。tsdown 的 Unbundle 模式(unbundle,也称 bundleless 或 transpile-only) 会按照与源码一一对应的关系逐文件编译输出,让 dist 目录完美复刻 src 目录结构,从而支持用户按子路径精确导入单个工具函数或组件。本文以 .agents/skills/tsdown/references/option-unbundle.md 为骨架,结合 Project AIRI 仓库中真实的 tsdown 配置(音频工具包、i18n 语言包、字体包等),完整讲解 unbundle 模式的配置方法、运行原理、适用场景、输出控制与 package.json 联动,帮助你在 monorepo 中落地"开发期毫秒级重建、发布后按需导入"的库构建方案。
什么是 tsdown 的 unbundle 模式
Unbundle 模式(又称 "bundleless" 或 "transpile-only")的核心思想是:输出文件与源文件结构一一镜像,而不是把全部代码打包进少数几个文件。开启后,每个源文件都会被单独编译,形成一对一的映射关系。
tsdown 本身是基于 Rolldown 与 Oxc 的库构建工具(见 .agents/skills/tsdown/SKILL.md),unbundle 模式正是它面向"以源码结构为发布形态"的库所提供的能力开关。
开启方式
CLI 方式:
tsdown --unbundle
配置文件方式(所有包通常位于 packages/* 下的 tsdown.config.ts):
export default defineConfig({
entry: ['src/**/*.ts', '!**/*.test.ts'],
unbundle: true,
})
工作原理:从源码树到输出树
以一个典型的源码目录为例:
src/
├── index.ts
├── utils/
│ ├── helper.ts
│ └── format.ts
└── components/
└── button.ts
开启 unbundle:结构与源码一一对应
配置只指定 src/index.ts 作为入口:
export default defineConfig({
entry: ['src/index.ts'],
unbundle: true,
})
输出结果为(默认 ESM 输出扩展名为 .mjs):
dist/
├── index.mjs
├── utils/
│ ├── helper.mjs
│ └── format.mjs
└── components/
└── button.mjs
所有被 index.ts 间接导入的文件都会各自输出一份,目录层次保持不变。这正是本仓库 packages/* 各包输出形态的直接来源——例如 packages/i18n/package.json 与 packages/audio/package.json 的 exports 子路径都精确指向 dist 下带目录前缀的 .mjs 文件。
默认关闭(标准打包):全部收敛为一个文件
不配置 unbundle(默认值),结果只有一个文件:
dist/
└── index.mjs (all code bundled together)
依赖关系被打包器静态分析后合并,运行时会互相内联,源码的目录边界在产物中消失。
何时使用 unbundle、何时使用标准打包
原文档给出的决策清单如下。
建议使用 unbundle:
- 构建 monorepo 内共享的公共工具包
- 用户需要按需导入单个模块(
my-lib/utils/helper) - 希望产物到源码有清晰的映射关系,便于调试定位
- 提供大量相互独立的工具函数/组件的库
- 调试时需要追踪具体文件
- 需要增量构建、追求更快的开发期重建
建议保留标准打包:
- 只有一个入口点的应用
- 希望最小化整体产物体积
- 需要激进的 tree shaking
- 需要产出 IIFE / UMD 形态
- 面向浏览器直接部署
仓库中的真实用例:三种典型 unbundle 配置
Project AIRI 仓库的 packages/ 目录提供了多份可直接对照学习的真实配置。
用例一:结构化子入口的音频工具包
packages/audio/tsdown.config.ts 采用"对象形式入口 + unbundle":
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
'index': 'src/index.ts',
'audio-context/index': 'src/audio-context/index.ts',
'audio-context/processor.worklet': 'src/audio-context/processor.worklet.ts',
'encoding/index': 'src/encoding/index.ts',
},
unbundle: true,
external: [
'@alexanderolsen/libsamplerate-js/dist/libsamplerate.worklet.js?worker&url',
'./processor.worklet?worker&url',
],
})
对象形式的键名直接决定产物的子路径前缀,因此其 package.json 中(packages/audio/package.json)的 exports 可以声明 ./audio-context、./audio-context/processor.worklet、./encoding 等深度子路径,且 .d.mts 类型文件一一对应:
{
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./audio-context/processor.worklet": {
"types": "./dist/audio-context/processor.worklet.d.mts",
"default": "./dist/audio-context/processor.worklet.mjs"
}
}
}
值得注意,这里通过 external 把 AudioWorklet 相关的 ?worker&url 资源排除在打包之外,保证 worklet 文件以独立 URL 形式保留引用——unbundle 与 external 常常需要组合使用。
用例二:多语言 i18n 包的 glob 入口与插件协作
packages/i18n/tsdown.config.ts 把 unbundle、copy 和 rolldown 插件组合在一起:
import Yaml from 'unplugin-yaml/rolldown'
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
'index': 'src/index.ts',
'locales/index': 'src/locales/index.ts',
'locales/en/index': 'src/locales/en/index.ts',
'locales/zh-Hans/index': 'src/locales/zh-Hans/index.ts',
},
copy: [
{ from: 'src/locales', to: 'dist/locales' },
],
unbundle: true,
plugins: [
Yaml(),
],
})
语言资源(.yaml)并不适合逐模块编译,因此通过 copy 原样落到 dist/locales;TS 入口则在 unbundle 模式下被编译成各自的 index.mjs,最终在 packages/i18n/package.json 中以 ./locales/en、./locales/zh-Hans、甚至 ./locales/*.yaml 通配子路径暴露给上层应用按需加载。这验证了 unbundle 特别适合"结构化子模块 + 独立资源"混合型包。
用例三:字体包的纯透传
字体包通常只是把字体二进制与对应的 CSS 声明暴露出去,packages/font-xiaolai/tsdown.config.ts 展示了极简形态:
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
external: ['./index.css'],
copy: [
{ from: 'src/files', to: 'dist' },
{ from: 'src/index.css', to: 'dist' },
],
unbundle: true,
})
常见配置模式
工具函数库:ESM + CJS + DTS 全量输出
export default defineConfig({
entry: ['src/**/*.ts', '!**/*.test.ts'],
format: ['esm', 'cjs'],
unbundle: true,
dts: true,
})
收益包括:用户只导入他们需要的模块;tree shaking 由用户侧的打包器在构建期完成;模块边界清晰。使用端可以这样按子路径导入:
// Users can import specific utilities
import { helper } from 'my-lib/utils/helper'
import { Button } from 'my-lib/components/button'
monorepo 共享包
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
unbundle: true,
outDir: 'dist',
})
纯 TypeScript 转译(不做优化)
export default defineConfig({
entry: ['src/**/*.ts'],
format: ['esm'],
unbundle: true,
minify: false,
treeshake: false,
dts: true,
})
这相当于把 tsdown 当作纯 TS→JS 转换器使用,输出与源码同构。
开发/生产差异化配置
利用 defineConfig 的函数重载,让 watch 时走 unbundle(秒级重建),发布时走打包优化:
export default defineConfig((options) => ({
entry: ['src/**/*.ts'],
unbundle: options.watch, // Unbundle in dev only
minify: !options.watch,
}))
开发期快速重建、生产期优化产物,这与 .agents/skills/tsdown/SKILL.md 中 options.watch 条件配置的用法一脉相承。
与 entry 模式配合
包含 / 排除
export default defineConfig({
entry: [
'src/**/*.ts',
'!**/*.test.ts',
'!**/*.spec.ts',
'!**/fixtures/**',
],
unbundle: true,
})
被排除的文件不会进入产物目录。
多个入口点
export default defineConfig({
entry: {
index: 'src/index.ts',
cli: 'src/cli.ts',
},
unbundle: true,
})
所有入口文件以及它们导入的文件都会被保留并独立输出。
输出控制
自定义扩展名
默认 ESM 输出为 .mjs;需要与 package.json 的 "type": "commonjs" 等约定对齐时,可用 outExtensions 改回 .js:
export default defineConfig({
entry: ['src/**/*.ts'],
unbundle: true,
outExtensions: () => ({ js: '.js' }),
})
在本仓库的实践里,packages/audio/package.json 等包声明
"type": "module",产物统一为.mjs/.d.mts,因此无需覆盖扩展名——选择哪种由包的类型约定决定。
修改输出目录
export default defineConfig({
entry: ['src/**/*.ts'],
unbundle: true,
outDir: 'lib',
})
产物布局:
lib/
├── index.js
├── utils/
│ └── helper.js
└── components/
└── button.js
通过 root 控制目录映射起点
默认情况下 tsdown 会把所有入口的公共目录作为输出根;当你希望保留 src/ 前缀,或让 unbundle 模式的结构映射从指定目录开始,可以用 root 显式控制(详见 Root Directory 选项)。例如:
export default defineConfig({
entry: ['src/**/*.ts', '!**/*.test.ts'],
root: '.', // 保留 src/ 前缀
unbundle: true,
})
在 unbundle 模式下,root 会作为模块结构保留的根(preserveModulesRoot)参与输出路径计算,是与 unbundle 强相关、需要一并理解的配套选项。
package.json 联动:把子路径暴露给用户
unbundle 的价值最终要靠 exports 字段兑现。以 ESM 为例,完整的手写形态如下:
{
"name": "my-library",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./utils/*": "./dist/utils/*.js",
"./components/*": "./dist/components/*.js"
},
"files": ["dist"]
}
其中通配子路径 "./utils/*": "./dist/utils/*.js" 是配合 unbundle 产物最常用的写法——它允许用户按任意深度导入具体文件。tsdown 也支持 exports: true 由构建输出自动推断生成 exports 字段,并可用 exports: { all: true } 把所有输出文件(而不只是入口)加入导出清单;更细化的 devExports、legacy 等能力见 package exports 选项。本仓库的 packages/i18n/package.json 即展示了逐子路径手写 exports 并配合 "./locales/*.yaml" 资源通配的真实形态,可供对照。
打包 vs unbundle:特性对比
原文档给出的对比表:
| 特性 | 打包(Bundled) | 非打包(Unbundled) |
|---|---|---|
| 输出文件数 | 少 | 多 |
| 文件体积 | 更小 | 更大 |
| 构建速度 | 更慢 | 更快 |
| Tree shaking | 构建期完成 | 交给用户构建期 |
| 源码映射 | 复杂 | 简单 |
| 模块导入 | 仅限入口 | 任意模块 |
| 开发期重建 | 更慢 | 更快 |
性能特征
构建速度:unbundle 通常更快——省去打包聚合的开销、文件可并行处理、且天然支持增量构建。
产物体积:unbundle 输出整体更大——每个文件各自带有模块开销、缺少跨模块优化,最终优化需交给用户侧的打包器。
需要注意的是,原文档给出的是定性结论,具体倍率依赖项目规模、文件数量与依赖图复杂度,不应被当作普适性能承诺。
实践要点速览
- 配合 glob 模式处理多个源文件;
- 开发期开启以获得更快的重建;
- 让用户侧打包器完成生产环境的最终优化;
- 保留目录结构,适合工具函数与组件库;
- 务必开启 DTS,让每个模块都有独立类型声明(dts 生成细节见 DTS 选项);
- 结合 monorepo,作为共享代码包的标准构建形态。
常见问题排查
输出文件过多:
- 收窄 entry 的 glob 模式
- 排除不需要的文件(如
!**/*.test.ts、!**/fixtures/**) - 改用明确的入口点列表
产物缺失某些文件:
- 检查 entry 模式是否覆盖该文件
- 确认该文件确实被某个入口直接或间接 import
- 排查是否命中排除规则
导入路径解析失败:
- 核对产物目录结构与源码相对路径是否一致(必要时用
root修正映射起点) - 核对 outDir、entry 命名
- 更新
package.json的exports,为新增子路径补充映射,并用tsdown --exports --publint校验导出声明
CLI 常用示例
# Enable unbundle
tsdown --unbundle
# With specific entry
tsdown src/**/*.ts --unbundle
# With other options
tsdown --unbundle --format esm --dts
--format、--dts 的完整语义可参见 Output Format 选项 与 DTS 选项。
延伸阅读
unbundle 通常与其他输出类选项搭配使用,仓库内 references 目录下的相关文档均可继续深入:
- Root Directory 选项 —— 控制输出目录映射起点,与 unbundle 结构直接相关
- Entry 选项 —— 入口点与 glob 模式
- Output Directory 选项 —— 输出位置与扩展名控制
- Output Format 选项 —— ESM/CJS 等模块格式
- DTS 选项 —— 类型声明生成
- Package Exports 选项 —— 自动生成 exports 字段
仓库中的真实落地示例(含完整配置与对应的 exports 声明)可继续阅读 packages/audio/tsdown.config.ts、packages/i18n/tsdown.config.ts、packages/font-xiaolai/tsdown.config.ts,以及 .agents/skills/tsdown/SKILL.md 中的 "Preserve Directory Structure" 章节。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00