watch mode VS stub mode VS devExports

Library 的开发模式

monorepo 下开发 Library 时,为了获得良好的开发体验(即代码变更后能即时生效),通常有以下三种开发模式可供选择:

  • Watch Mode
  • Stub Mode
  • DevExports

Watch Mode 通过构建工具监听文件变更,实时构建并输出到目标目录,供其他包引用(常见工具如 vitetsupesbuildrollup 等)。

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)。

例如,若初始代码如下:

foo.ts
ts
export const foo = 'foo'

修改后增加了导出:

foo.ts
ts
export const foo = 'foo'
export const bar = 'bar'

Stub Mode 下此时会报错,提示 bar 未定义。这是因为生成的 Stub 文件(如下所示)仅记录了初次生成的快照:

foo.js
js
/** @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(常见于 tsdownpnpm 开发模式)利用 package.jsonexports 字段,实现开发阶段构建直接指向源码文件,而生产阶段指向构建产物。

package.json
json
{
  "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"
      }
    }
  }
}

优点

  • 无需 buildstub,直接源码引用。
  • 在纯 TypeScript 项目工作流中,这是一种非常理想的方案。

限制与挑战

例如,在 Vite 工程中,资源的引用通常没有问题(因为会经过 Vite 转换管道)。 但是,如果在 vite.config.ts 一般配置文件中引用该库,则会遇到问题。

因为 vite.config.ts 运行在 Node.js 环境下,作为裸模块(Bare Import)引用的依赖通常不会被 Vite 的插件管道处理。若直接指向 .ts 源码,Node.js 会报错无法解析。

虽然可以尝试改用相对路径引用(会被识别并转换),但这会带来新的问题:

  • 依赖隔离问题:如果该库依赖了其他 Workspace 包,这些依赖包也必须符合 Node 解析策略(即必须是编译后的产物),不能指向源码。

适用场景: 适用于依赖关系简单、主要被应用源码引用(而非配置文件引用)的代码库;或者能够接受在特定配置文件中使用相对路径内联导入的场景。

总结

核心目标:实现直接导入 ts 文件,享受即时生效的开发体验。

核心考量:进程开销、构建产物一致性、运行时性能、场景适配复杂度。

特性Watch ModeStub ModeDevExports
进程开销高 (需挂载额外进程)低 (无需额外进程)低 (无需额外进程)
开发体验良好 (轻微延迟)极佳 (即时)极佳 (即时)
产物一致性✅ 保证❌ 不能保证✅ 保证
运行时开销有 (运行时编译)
适用场景通用,适配各类复杂场景小型项目,导出固定纯 TS 项目,依赖关系简单
复杂构建支持✅ 支持 (插件管道完整)❌ 不支持❌ 受限 (配置文件引用受限)

选择建议:

  • 通用首选Watch Mode,最稳妥的选择。
  • 尝鲜/极简Stub Mode / DevExports,在此类特定小规模场景下能提供更极致的体验。
patch ElSelect 组件支持 focus选中