引言
一个仓库装下前端、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 的收益与代价
- 2. pnpm workspace 基础
- 3. Turborepo 任务编排与缓存
- 4. 包依赖图与类型边界
- 5. tsconfig 与 project references
- 6. 共享配置与构建产物
- 7. changesets 版本与发布
- 8. CI 优化:缓存命中与并行任务
- 9. 多包协作的常见坑
- 10. 速查表与一句话记忆
- 延伸阅读
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 不升 B | changesets 统一推进 |
| 构建放大 | 改一行全仓重编 | 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 专题 — 包管理与工程化
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。