背景与问题界定

随着前端项目规模的扩大,monorepo 已经成为中大型项目的标准组织方式。pnpm workspace + TypeScript + turborepo 的组合方案逐渐成为主流技术栈。但在 monorepo 中使用 TypeScript 面临着几个特有的挑战:首先是类型共享问题——公共类型定义放在哪个包中?如何在保持类型一致性的同时避免循环依赖?其次是构建性能——在包含 20+ 子包的项目中,全量类型检查耗时可达 30 秒以上,极大地影响了开发体验和 CI 流水线效率。再者是发布约束——当 A 包的类型定义引用了 B 包,A 的发布版本如何与 B 的版本保持一致?这些问题如果不在工程化层面系统性解决,monorepo 带来的类型一致性红利很快会被构建和发布摩擦抵消。

目标拆解与工程约束

  • 类型包的独立版本管理:通用类型定义放在独立的 @company/types 包中,遵守 semver,变更时通过 changesets 自动生成 changelog。
  • 增量编译替代全量编译:使用 TypeScript 的 Project Referencestsc --build 实现依赖感知的增量编译,大幅减少全量检查的场景。
  • 类型可见性控制:包的 package.jsontypes 字段只暴露公共类型,内部类型通过 tsconfig.jsonpaths 映射避免被外部引用。
  • 构建管道的缓存优化:结合 turborepo 的缓存策略,未变更包的编译产物直接复用缓存,CI 构建时间控制在 3 分钟以内。

方案设计

类型共享架构

在 monorepo 中,类型共享有三种模式:集中式类型包分布式内联类型接口契约生成。我们推荐集中式类型包作为默认方案。

集中式类型包 ( packages/types ) 放置所有跨包共享的类型定义,包括 API 请求/响应 DTO、配置接口、事件定义和常量枚举。其他包通过 @company/types 导入。这种模式的优点是类型定义单一来源,不存在多个副本的同步问题。缺点是可能演进为巨大的类型垃圾场,需要通过子域拆分和定期清理来控制:

// packages/types/src/api/user.ts
export interface UserDTO {
  id: string
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
  createdAt: string
}

export interface CreateUserRequest {
  name: string
  email: string
  role: UserDTO['role']
}

export type UserApiResponse<T> = {
  success: true
  data: T
} | {
  success: false
  error: { code: string; message: string }
}

对于跨包间数量较少且稳定的类型引用,也可以接受局部共享——通过包间的直接类型依赖实现,但必须在 tsconfig.json 中正确配置引用关系。

构建优化策略

TypeScript 的 Project References 是实现 monorepo 增量编译的官方方案。每个子包作为一个独立的 Project,顶层 tsconfig.json 通过 references 声明依赖关系。tsc --build 命令会智能地只重新编译有变更的包及其下游依赖。

配合 turborepo,我们在 turbo.json 中配置缓存策略,将每个包的 tsc --build 输出缓存到远程缓存(如 Vercel Remote Caching 或 S3):

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".tsbuildinfo/**"],
      "inputs": ["src/**/*.ts", "tsconfig.json"],
      "cache": true
    }
  }
}

.tsbuildinfo 文件是增量编译的关键——tsc 通过它记录每个文件的编译状态和依赖关系,后续编译只处理变更文件。需要将该文件纳入缓存路径,并确保不同机器环境的构建不会因为路径差异导致缓存失效。

类型发布约束

使用 @changesets/cli 管理包的版本和 changelog。当类型包发生 breaking change 时,所有依赖该类型包的包都需要触发 major 版本更新,通过 changesets 的 dependent 策略自动处理。

实施路径与关键决策

  • 首选 pnpm workspace:pnpm 的严格依赖隔离比 npm/yarn workspace 更适合 monorepo 场景,避免幽灵依赖导致的类型解析错误。
  • 启用 declarationMap.d.ts.map 文件让 IDE 可以直接跳转到类型定义的源文件,提升调试体验。
  • 统一的 tsconfig 基础配置:通过 extends 链统一所有子包的 compilerOptions,减少重复配置和配置漂移。
  • CI 中的类型检查分离:lint、类型检查、单元测试、构建在 CI 中分阶段执行,类型检查失败时快速失败,不浪费后续步骤的资源。

验证指标与可持续迭代

实施后,全量构建时间从 35 秒降低到 8 秒(增量构建 2 秒以内)。缓存命中率保持在 70% 以上。类型包索引化的类型定义覆盖所有跨包共享的数据结构(100%)。后续迭代方向包括:TypeScript 5.x 的 isolatedDeclarations 模式在 monorepo 中的适用性评估,以及基于 Bazel / Nx 的极端规模化构建优化。

工程落地思考

Monorepo 的类型共享本质上是在解决一个古老的软件工程问题——耦合与内聚的边界。类型包就像架构图中的接口定义,它定义了不同模块之间的通信契约。一个好的类型包不应是"所有类型的集合",而应是"所有跨模块接口的集合"。内部实现类型应留在各自的包中,只暴露经过精心设计的公共 API 类型。这个原则不仅适用于类型定义,也适用于整个软件架构——清晰的边界是 monorepo 项目能否持续扩展的关键。当一个包的类型引用关系罗盘变成蜘蛛网时,正是考虑拆分 monorepo 为多个独立仓库的信号。