TypeScript Monorepo 工程化:pnpm、Turborepo 与多包协作

系统覆盖 TypeScript Monorepo 的完整工程化实践:monorepo 收益与代价的理性评估、pnpm workspace 与内容寻址存储、Turborepo 缓存与任务编排、包依赖图与类型边界、tsconfig project references、共享配置与构建产物、changesets 版本与发布流程、CI 缓存命中与并行任务,以及多包协作的常见坑,帮助团队把多包仓库从「依赖地狱」变成可缓存、可发布、可协作的工程体系。

引言

一个仓库装下前端、CLI、共享类型包与后端服务,是很多团队的理想,也是很多团队的噩梦。收益很实在:跨包改动一次 PR、类型/常量直接引用、依赖统一升级;代价同样真实:构建慢、依赖纠缠、版本失控。本文给出一套可落地的组合拳——pnpm workspace 管依赖、Turborepo 管缓存与任务编排、tsconfig project references 卡类型边界、changesets 管版本与发布、CI 里靠缓存命中与并行把全仓构建压缩到分钟级。

前置:/typescript-project-architecture-tsconfig/(tsconfig 分层)、/typescript-build-performance-optimization/(构建优化)、/typescript-sdk-package-publishing/(包发布)。

目录

1. monorepo 的收益与代价

先理性评估「要不要 monorepo」。

收益:原子变更(跨包改动一次提交,CI 一起验证,杜绝「发 A 等 B 跟上」);类型直连(包间直接 import 构建产物,类型不需要先发布再装包);统一依赖(一份 lockfile,版本全网一致);复用(共享 tsconfig/eslint/脚本)。

代价:构建放大(改一个包可能触发全仓编译——要缓存兜底);依赖纠缠(不收敛时循环与隐式依赖滋生);发布复杂(多包版本推进要 changesets);CI 变慢(要任务编排 + 缓存)。

决策信号:两个以上 TS 包共享类型/常量且需原子发布 → 划算;单包 + 独立部署 → 别上。

2. pnpm workspace 基础

pnpm 用 内容寻址存储 + 符号链接,比 npm/yarn 省磁盘更快:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"
pnpm install             # 生成单一 lockfile
pnpm add -w typescript   # -w 加到仓库根 devDeps
pnpm --filter @plume/ui add react      # 只给某包加依赖
pnpm --filter @plume/server run dev    # 只跑某包

核心机制:单一 pnpm-lock.yaml(CI 里 pnpm install --frozen-lockfile);符号链接依赖——node_modules 只有直接依赖,peer 严格隔离,杜绝幽灵依赖;store 全局共享,pnpm store prune 清理。工程要点:onlyBuiltDependencies 禁 postinstall 脚本(供应链安全),esbuild/sharp 等显式白名单;shamefully-hoist 别开——它是 npm 行为模拟,开了等于放弃隔离。

3. Turborepo 任务编排与缓存

Turborepo 解决「全仓构建慢」:声明任务依赖图,命中缓存直接跳过。

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json"],
  "tasks": {
    "build": {
      "dependsOn": ["^build"],          // 依赖包先 build
      "outputs": ["dist/**"],           // 缓存这些产物
      "inputs": ["src/**", "package.json", "tsconfig*.json"]
    },
    "test": { "dependsOn": ["build"] },
    "typecheck": { "dependsOn": ["^typecheck"] }
  }
}
turbo run build test --filter=@plume/server   # 只跑某包及其依赖
turbo run build --force                        # 强制重跑

缓存关键点:hash 依据 = 脚本 + 输入文件内容 + 全局依赖——改一个 .ts 只让受影响包失效;远程缓存(Vercel/S3)让 CI 与本地共享,跨机器命中;outputs 漏写产物目录 = 命中但产物缺失;任务读 process.env 时把变量写进 env 列表,否则换 env 也命中旧缓存。坑:dependsOn: ["^build"] 只等依赖包 build,自己包内 task 顺序要靠 dependsOn 显式声明。

4. 包依赖图与类型边界

多包协作的根基是清晰依赖图。用 workspace 协议表达包间关系:

{
  "name": "@plume/core",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
  },
  "devDependencies": { "@plume/ui": "workspace:*" }
}

依赖方向铁律:单向(shared ← ui ← apps/web),禁止反向或循环;粒度收敛(一个包只暴露明确 API,内部实现不导出);类型边界卡在 exports.types(编译器只看 d.ts 不看实现,类型即契约);工具包下沉(多应用复用放 packages/,单应用独有留 apps/*/src)。坑:workspace:* 发布时被 pnpm 替换成真实版本号,但要走 changesets(§7),否则版本原地不动。

5. tsconfig 与 project references

project references 把「全仓一次编译」拆成「按依赖增量」:

// tsconfig.base.json
{
  "compilerOptions": {
    "strict": true, "target": "es2022", "module": "nodenext",
    "composite": true, "declaration": true, "declarationMap": true
  }
}
// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": { "outDir": "dist", "rootDir": "src" }
}
// apps/web/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "references": [{ "path": "../../packages/ui" }, { "path": "../../packages/core" }]
}

关键认知:composite: true + references 让 tsc 按依赖图增量构建,tsc -b 从根一键全建;declarationMap 让跨包「Go to Definition」跳进被引用包源码而不是 .d.ts;noEmit 的纯类型包不能进 references,要独立 tsconfig;Turborepo 的 ^build 管构建顺序,references 让 tsc 只重编译受影响包,二者互补。

6. 共享配置与构建产物

「一处配置,全网生效」是复利来源:

{
  "devDependencies": { "typescript": "^5.5", "turbo": "^2", "eslint": "^9", "prettier": "^3" },
  "scripts": {
    "build": "turbo run build",
    "test": "turbo run test",
    "typecheck": "turbo run typecheck",
    "dev": "turbo run dev --parallel"
  }
}

构建产物策略:每个包独立 build(turbo run build 输出到各包 dist/,exports 指向产物);开发时 apps/* 通过 tsup/vite alias 直接引源码,免「改共享包要重 build」;dist/ 加 .gitignore,CI 构建、发布包打包产物;eslint/prettier 配置放根目录,各包 extends,规则全网一致。坑:exports 漏写 types 条件会让跨包 import 报「找不到模块声明」——发布前 pnpm pack --dry-run 检查 dist/index.d.ts 是否在包内。

7. changesets 版本与发布

多包版本管理用 changesets:改动即提交变更集,发布自动算版本、写 CHANGELOG。

pnpm add -w -D @changesets/cli
pnpm changeset init   # 生成 .changeset/ 目录
pnpm changeset        # 交互式:选受影响包 + 版本级别
<!-- .changeset/silly-owls-smile.md -->
---
"@plume/core": minor
"@plume/ui": minor
---
新增关系型数据加载 API,支持 include 预加载。

发布流程:

pnpm changeset version     # 按变更集更新版本 + CHANGELOG
git add -A && git commit -m "chore: version packages"
pnpm publish -r --no-git-checks    # 按依赖拓扑从底向上发布
pnpm changeset status      # CI 卡点:有 packages/ 改动但无变更集则失败

发布纪律:changeset 是唯一改版本的方式(别手动改 package.json,否则版本漂移);CI 强制 changeset status 逼开发者记录变更;变更集描述写「用户可见变更」,它进 CHANGELOG;pnpm publish -r 按拓扑发布,消费者永远拿到新版本。

8. CI 优化:缓存命中与并行任务

CI 目标:一次变更,只重跑受影响的验证。

name: CI
on: [pull_request]
jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile

  validate:
    needs: install
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run lint typecheck test --filter='./apps/*' --filter='./packages/*'

优化清单:turbo 远程缓存(S3/Redis,本地 build 命中后 CI 无需重跑);--filter 收窄(PR 只动 apps/web 不跑全仓 test);lint/typecheck/test/build 拆并行 job 汇总 gate;--frozen-lockfile 让 lockfile 变更即失败;缓存 key 用 hashFiles 精确命中。坑:手动拼 actions/cache 不如 turbo 远程缓存省心;.gitignore 漏 dist 会让缓存 hash 与本地不一致,永远 miss。

9. 多包协作的常见坑

坑症状对策
幽灵依赖没声明却 import 成功,升级后崩pnpm 隔离 + eslint-plugin-import
循环 import启动报 undefined依赖单向化,下沉公共包
exports 缺失跨包类型找不到pnpm pack --dry-run 查 d.ts
版本漂移发 A 不升 Bchangesets 统一推进
构建放大改一行全仓重编Turborepo 缓存 + filter

另两个高频坑:dist 进 git——缓存 hash 与本地不一致,CI 缓存永远 miss;devDependencies 放错层——只在包内用的 @types/* 放包内,根只放跨包共享工具,避免「根有 types、包编译找不到」。

10. 速查表与一句话记忆

环节工具与做法
依赖管理pnpm workspace + 单一 lockfile + frozen
任务编排Turborepo dependsOn: ["^build"] + 缓存
类型边界tsconfig references + exports.types + composite
版本发布changesets(变更集 → version → publish -r)
CI 加速远程缓存 + --filter 收窄 + 并行 job
依赖方向shared ← ui ← apps,禁止反向

一句话记忆:Monorepo = pnpm 锁依赖(内容寻址 + 隔离)+ Turborepo 管任务(缓存 + 拓扑并行)+ references 卡类型(增量构建 + 声明映射)+ changesets 管版本(变更集驱动发布)+ CI 靠缓存命中(远程缓存 + filter 收窄)。

延伸阅读

  • /typescript-project-architecture-tsconfig/ — tsconfig 分层与引用
  • /typescript-build-performance-optimization/ — 构建性能与缓存
  • /typescript-sdk-package-publishing/ — 包发布与 exports 规范
  • /typescript-api-type-generation/ — 跨包类型契约
  • /typescript-strict-config/ — 严格编译配置
  • Node.js 专题 — 包管理与工程化

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. TypeScript 应用安全加固:依赖、注入与敏感信息防护
  2. Node.js Worker Threads:TypeScript 并行计算实战
  3. NestJS 微服务架构:模块化、消息与网关