外观
Monorepo 工程化:pnpm workspace + Turborepo
当前端团队同时维护 Web 应用、管理后台、组件库和工具包时,多仓库模式会带来几个固定成本:组件库改一个类型,要在多个仓库里升级、发版、联调;公共配置重复维护;跨仓库重构几乎不可能。
Monorepo 把相关项目放进同一个仓库,用工具链保证依赖、构建和发布的一致性。它的收益很大,但如果没有约束,也很容易变成一个巨大而混乱的仓库。
Monorepo 适合什么
适合:
- 多个应用共享组件库、工具函数、类型定义。
- 应用之间有明确的协作关系,经常需要一起修改。
- 团队有能力维护统一的工程配置和 CI。
- 组件库需要独立发布,但与应用联调频繁。
不适合:
- 项目之间毫无关系,只是"放在一起方便管理"。
- 团队规模很小,一个应用都还没有稳定的测试和发布流程。
- 没有专人维护构建工具链。
Monorepo 是一种组织方式,不是技术目标。它解决的是协作成本,代价是工具链复杂度。
目录结构
一个典型的 pnpm workspace:
text
acme/
├── apps/
│ ├── web/ # 面向用户的 Web 应用
│ └── admin/ # 管理后台
├── packages/
│ ├── ui/ # 组件库
│ ├── utils/ # 工具函数
│ ├── api-client/ # 接口客户端与类型
│ ├── eslint-config/ # 共享 ESLint 配置
│ └── tsconfig/ # 共享 TypeScript 配置
├── package.json
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yamlapps 放可部署的应用,packages 放被复用的库。依赖方向必须单向:应用依赖包,包之间尽量不互相依赖,禁止包反向依赖应用。
pnpm workspace 基础配置
yaml
# pnpm-workspace.yaml
packages:
- apps/*
- packages/*
catalog:
vue: ^3.5.0
typescript: ^5.6.0
vitest: ^2.1.0catalog 是 pnpm 的版本目录,统一声明依赖版本,子包通过 catalog: 引用:
json
{
"name": "@acme/ui",
"version": "1.0.0",
"private": false,
"dependencies": {
"vue": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
}
}应用引用内部包时使用 workspace 协议:
json
{
"name": "@acme/web",
"private": true,
"dependencies": {
"@acme/ui": "workspace:*",
"@acme/utils": "workspace:*"
}
}workspace:* 在发布时会被替换成真实版本号。内部包不要手动写版本号再靠本地 link,容易出现版本错配。
pnpm 默认使用严格依赖结构,子包只能访问自己声明的依赖。这能有效避免 phantom dependencies(未声明却因为提升而可用),迁移时可能会暴露一批隐藏问题,属于好事。
Turborepo:任务编排与缓存
Monorepo 中运行 build 时,必须按依赖关系排序:先构建 ui,再构建依赖它的 web。Turborepo 通过任务图自动处理:
json
{
"$schema": "https://turbo.build/schema.json",
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"],
"env": ["VITE_API_BASE"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}几个关键字段:
dependsOn: ["^build"]:先执行所有依赖包的build。outputs:声明产物路径,缓存命中时直接恢复这些文件。env:影响构建结果的环境变量。漏声明会导致缓存命中错误产物。cache: false:开发服务器不能缓存。persistent: true:长期运行的任务,Turborepo 不会等待它结束。
根 package.json:
json
{
"name": "acme",
"private": true,
"packageManager": "pnpm@9.12.0",
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint"
},
"devDependencies": {
"turbo": "^2.3.0"
}
}常用命令:
bash
# 只构建 web 及其依赖
pnpm turbo run build --filter=@acme/web
# 构建受本次改动影响的包
pnpm turbo run build --filter='...[origin/main]'
# 只运行某个包的脚本
pnpm --filter @acme/ui test远程缓存:CI 提速的关键
本地缓存只能在同一个开发者机器上复用。开启远程缓存后,CI 和团队成员可以共享构建产物:
bash
turbo login
turbo link在 CI 中配置:
yaml
# .github/workflows/ci.yml
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run lint test build远程缓存生效的前提是输入稳定:lockfile、源码、环境变量、依赖版本,任何一项变化都会让缓存失效。如果发现 CI 频繁不命中,先检查 env 和 globalEnv 是否遗漏。
版本管理与发布
内部包如果只供本仓库使用,设置 private: true,不需要发布。需要对外发布时,使用 Changesets 管理版本和 changelog:
bash
pnpm add -Dw @changesets/cli
pnpm changeset
pnpm changeset version
pnpm changeset publish每次改动由开发者描述变更类型(patch/minor/major),发布时自动计算版本并生成更新日志。比手动改版本号可靠得多。
发布前确保包已经构建。可以在 package.json 中使用 publishConfig 和 files 控制产物:
json
{
"files": ["dist"],
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}常见坑
- 没有依赖边界。
utils反向依赖某个应用,或包之间循环依赖,任务图会直接失效。 - 缓存输出声明不全。只写
dist/**却漏掉types/或静态资源,命中缓存后产物缺失。 - 环境变量未纳入缓存键。不同环境的构建结果互相污染,排查成本极高。
- 所有包都发版。内部包频繁发布没有意义,使用
private和 Changesets 的 ignore 配置控制。 - CI 安装依赖不锁版本。使用
--frozen-lockfile,否则 CI 拉到的依赖可能与本地不一致。 - 忽略仓库体积。大文件、构建产物、缓存目录要加入
.gitignore,否则 clone 和 CI 会越来越慢。
小结
Monorepo 的核心是统一依赖、统一任务、统一发布。pnpm 解决依赖结构和版本一致性,Turborepo 解决任务编排和缓存,Changesets 解决版本发布。工具之外,更重要的是明确的包边界和依赖规则,否则再好的工具也只能加速一个混乱的仓库。