Library 的开发模式
在 monorepo 下开发 Library 时,为了获得良好的开发体验(即代码变更后能即时生效),通常有以下三种开发模式可供选择:
- Watch Mode
- Stub Mode
- DevExports
Watch Mode 通过构建工具监听文件变更,实时构建并输出到目标目录,供其他包引用(常见工具如 vite、tsup、esbuild、rollup 等)。
Stub Mode 是对源码生成一层代理文件(存根),利用 jiti 等即时编译工具对 ts 文件进行实时转译(如 unbuild)。
DevExports 则利用 package.json 中的 exports 字段,将开发阶段的构建入口直接指向源码文件,而生产阶段则指向构建产物。
本文将主要对比这三种模式的优缺点,并分析它们各自适配的使用场景。
Watch Mode
Watch Mode 是最常见的开发模式。其核心优势在于能够实时监听文件变更并触发构建,同时保证构建产物的完整性与一致性。
优点:
- 产物稳定,一致性高。
- 通用性强,适配绝大多数场景。
缺点:
- 需要额外占用一个终端进程进行监听。
- 对于大型项目,构建反馈可能存在延迟(构建速度取决于项目规模)。
搭配原生级(Native-based)构建工具使用时,体验通常较好。
Stub Mode
Stub Mode(常由 unbuild 提供)的原理是生成源码的代理文件(Stub),并通过 jiti 在运行时实时编译 ts 文件(类似 Node.js Loader 机制)。
优点:
- 无需启动额外进程监听文件变更,资源开销低。
- 对于小型项目,体验几乎是零延迟的。
缺点:
- 运行时性能开销大:依赖运行时编译,大型项目性能损耗明显。
- 一致性风险:可能出现由于环境差异导致的运行时错误。
- 构建能力受限:不适合包含复杂构建转换逻辑(如特定插件转换管道)的项目。
- ESM 支持存在缺陷:
假设我们在小型项目中只关注 ESM 问题。由于 Stub 机制通常是基于一次性快照生成的,不支持动态更新具名导出(Named Exports)。
例如,若初始代码如下:
export const foo = 'foo'修改后增加了导出:
export const foo = 'foo'
export const bar = 'bar'在 Stub Mode 下此时会报错,提示 bar 未定义。这是因为生成的 Stub 文件(如下所示)仅记录了初次生成的快照:
/** @type {import("foo.js")} */
const _module = await jiti.import('foo.ts')
export const foo = _module.foo可以看到,bar 并没有被代理导出。此时必须重新运行命令生成 Stub,这使得它在此时的便捷性甚至不如 Watch Mode。
适用场景: 比较适合代码库较小、且导出接口(Exports)相对固定的场景。如果库非常稳定基本不变更,直接 build 可能比 stub 更省事。此外,如果涉及 Workspace 依赖,被依赖的包也需符合 Node 解析策略(通常意味着需要被编译)。
DevExports
DevExports(常见于 tsdown 或 pnpm 开发模式)利用 package.json 的 exports 字段,实现开发阶段构建直接指向源码文件,而生产阶段指向构建产物。
{
"name": "my-lib",
"version": "1.0.0",
"exports": {
".": {
"import": "./src/index.ts", // 开发环境:直接指向源码
"require": "./src/index.ts"
}
},
"publishConfig": {
"exports": {
".": {
"import": "./dist/index.esm.js", // 发布环境:指向构建产物
"require": "./dist/index.cjs.js"
}
}
}
}优点:
- 无需
build或stub,直接源码引用。 - 在纯 TypeScript 项目工作流中,这是一种非常理想的方案。
限制与挑战:
例如,在 Vite 工程中,资源的引用通常没有问题(因为会经过 Vite 转换管道)。 但是,如果在 vite.config.ts 一般配置文件中引用该库,则会遇到问题。
因为 vite.config.ts 运行在 Node.js 环境下,作为裸模块(Bare Import)引用的依赖通常不会被 Vite 的插件管道处理。若直接指向 .ts 源码,Node.js 会报错无法解析。
虽然可以尝试改用相对路径引用(会被识别并转换),但这会带来新的问题:
- 依赖隔离问题:如果该库依赖了其他 Workspace 包,这些依赖包也必须符合 Node 解析策略(即必须是编译后的产物),不能指向源码。
适用场景: 适用于依赖关系简单、主要被应用源码引用(而非配置文件引用)的代码库;或者能够接受在特定配置文件中使用相对路径内联导入的场景。
总结
核心目标:实现直接导入 ts 文件,享受即时生效的开发体验。
核心考量:进程开销、构建产物一致性、运行时性能、场景适配复杂度。
| 特性 | Watch Mode | Stub Mode | DevExports |
|---|---|---|---|
| 进程开销 | 高 (需挂载额外进程) | 低 (无需额外进程) | 低 (无需额外进程) |
| 开发体验 | 良好 (轻微延迟) | 极佳 (即时) | 极佳 (即时) |
| 产物一致性 | ✅ 保证 | ❌ 不能保证 | ✅ 保证 |
| 运行时开销 | 无 | 有 (运行时编译) | 无 |
| 适用场景 | 通用,适配各类复杂场景 | 小型项目,导出固定 | 纯 TS 项目,依赖关系简单 |
| 复杂构建支持 | ✅ 支持 (插件管道完整) | ❌ 不支持 | ❌ 受限 (配置文件引用受限) |
选择建议:
- 通用首选:
Watch Mode,最稳妥的选择。 - 尝鲜/极简:
Stub Mode/DevExports,在此类特定小规模场景下能提供更极致的体验。