前言
随着前端工程越来越复杂,一个项目往往要同时维护多个应用(web 端、文档站、后台管理)和多个共享库(UI 组件库、工具函数库、业务逻辑库)。传统的「一仓一包」模式会带来三个痛点:
- 代码重复:多个项目各自复制一份工具函数,改一处要同步 N 处;
- 联调困难 :改个组件库,要手动
npm link或发个临时包才能在其他项目里验证; - 版本混乱:多个包独立发版,版本号、CHANGELOG 各管各的,难以统一管理。
Monorepo(单体仓库) 就是为解决这些问题而生:把所有项目放进一个 Git 仓库统一管理,配合现代化的工具链,实现「一处修改、处处生效、一键发布」。
本文将带你从 0 到 1,用 pnpm + Turborepo + Changesets 这套目前最主流的技术栈,搭建一个可落地的 Monorepo 工程,并把我在实战中踩过的 8 个坑 一并分享给你。
阅读建议:前半部分是完整的搭建教程(可照抄),后半部分是踩坑记录(强烈建议看完,能帮你省下大量排查时间)。
一、为什么是这三件套
在动手之前,先搞清楚每个工具「管什么」,以及为什么选它。
| 工具 | 职责 | 选型理由 |
|---|---|---|
| pnpm | 依赖管理 + workspace | 软链 + 硬链机制,安装快、省磁盘;workspace:* 协议天然支持 monorepo 包间引用 |
| Turborepo | 任务编排 + 缓存 | 自动推断依赖图执行顺序,增量构建 + 内容寻址缓存,秒级构建 |
| Changesets | 版本管理 + 发包 | 精细控制每个包的版本升级,自动生成 CHANGELOG,支持选择性发布 |
三者分工明确、互不冲突:pnpm 管「装什么」,Turborepo 管「怎么跑」,Changesets 管「怎么发」。
对比同类型方案
- Lerna vs Turborepo:Lerna 是「老大哥」,但已停止大规模更新,且没有成熟的缓存机制;Turborepo 是 Vercel 出品,构建缓存和任务编排更现代化。
- semantic-release vs Changesets:semantic-release 基于 commit message 全自动发版,适合 CI 全自动场景;Changesets 更「可控」,每次发版前人工确认版本级别,适合团队协作、需要人工 review 的场景。
二、初始化项目
2.1 创建根目录并初始化
bash
mkdir monorepo-demo && cd monorepo-demo
pnpm init
2.2 创建 workspace 配置
在根目录创建 pnpm-workspace.yaml,声明哪些目录下的包属于 workspace:
yaml
packages:
- "apps/*"
- "packages/*"
这里约定俗成地把「应用」放 apps/,把「可复用的库」放 packages/。
2.3 安装基础依赖
bash
pnpm add -Dw turbo typescript @changesets/cli prettier
-D:装到根目录的devDependencies-w:在 workspace 根目录安装(pnpm 默认禁止在根目录直接装依赖,必须加-w)
三、目录结构设计
一个清晰的目录结构是 Monorepo 的基础。我们以一个最小但完整的场景为例:
ruby
monorepo-demo/
├── apps/
│ ├── web/ # @vtian-dev/web --- 主应用,消费 ui & utils
│ └── docs/ # @vtian-dev/docs --- 文档站,消费 ui & utils
├── packages/
│ ├── ui/ # @vtian-dev/ui --- 共享 UI 组件库(依赖 utils)
│ └── utils/ # @vtian-dev/utils --- 共享工具函数库(最底层)
├── package.json # 根配置 + 统一脚本
├── pnpm-workspace.yaml # workspace 声明
├── turbo.json # Turborepo 任务流水线
├── tsconfig.base.json # 共享 TS 配置
└── .changeset/ # Changesets 配置
依赖关系是一个典型的「分层」结构:
less
@vtian-dev/utils ← 无依赖(基础工具层)
↓
@vtian-dev/ui ← 依赖 utils(组件层)
↓
@vtian-dev/web ← 依赖 ui + utils(应用层)
@vtian-dev/docs ← 依赖 ui + utils(应用层)
这个「依赖方向单向流动」的设计很重要:底层包不能反向依赖上层包,否则会出现循环依赖,Turborepo 也会报错。
四、核心配置文件
4.1 根 package.json
统一入口脚本,所有操作都在根目录执行:
json
{
"name": "monorepo-demo",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --parallel",
"lint": "turbo run lint",
"clean": "turbo run clean",
"changeset": "changeset",
"version-packages": "changeset version",
"publish-packages": "turbo run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "^3.0.0",
"prettier": "^3.5.3",
"turbo": "^2.5.4",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@9.15.0"
}
注意根包必须 "private": true,因为它本身不发布,只是「管理壳」。
4.2 turbo.json --- 任务流水线
这是 Turborepo 的核心配置文件,定义每个任务的执行规则:
json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^build"],
"outputs": []
},
"clean": {
"cache": false
}
}
}
关键字段解释:
| 字段 | 作用 |
|---|---|
dependsOn: ["^build"] |
^ 表示「先构建所有依赖包」。执行 web#build 前,会先跑完 utils#build 和 ui#build |
inputs |
参与缓存哈希计算的文件。这些文件没变,就命中缓存 |
outputs |
构建产物目录,用于缓存还原 |
cache: false |
该任务不缓存(如 dev、clean) |
persistent: true |
常驻任务(watch 模式),不会自己退出 |
4.3 tsconfig.base.json --- 共享 TS 配置
所有包继承这份基础配置,避免重复:
json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"esModuleInterop": true,
"isolatedModules": true
},
"exclude": ["node_modules", "dist"]
}
每个包的 tsconfig.json 只需继承并指定自己的输入输出:
json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
五、编写源码与包间依赖
5.1 packages/utils --- 最底层工具库
packages/utils/package.json:
json
{
"name": "@vtian-dev/utils",
"version": "0.1.0",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
},
"devDependencies": {
"tsup": "^8.4.0",
"typescript": "^5.8.3"
}
}
库包用
tsup打包,一次同时产出 CJS(.js)、ESM(.mjs)和类型声明(.d.ts),兼容性最好。
packages/utils/src/index.ts:
ts
/** 首字母大写 */
export function capitalize(str: string): string {
if (!str) return str
return str.charAt(0).toUpperCase() + str.slice(1)
}
/** 两数相加 */
export function add(a: number, b: number): number {
return a + b
}
/** 生成问候语(内部复用 capitalize) */
export function greet(name: string): string {
return `Hello, ${capitalize(name)}!`
}
5.2 packages/ui --- 依赖 utils 的组件库
packages/ui/package.json 关键部分是依赖声明:
json
{
"name": "@vtian-dev/ui",
"version": "0.1.0",
"dependencies": {
"@vtian-dev/utils": "workspace:*"
}
}
workspace:*协议是 pnpm 的杀手锏:它让包之间直接引用本地源码,pnpm 会自动把它链接到 workspace 内的实际包,无需发版到 npm 就能互相引用。
packages/ui/src/index.ts:
ts
import { capitalize } from '@vtian-dev/utils'
export type ButtonVariant = 'primary' | 'secondary' | 'danger'
export interface ButtonProps {
label: string
variant?: ButtonVariant
}
export function renderButton({ label, variant = 'primary' }: ButtonProps): string {
return `<button class="btn btn-${variant}">${capitalize(label)}</button>`
}
5.3 apps/web --- 应用层消费
apps/web/package.json:
json
{
"name": "@vtian-dev/web",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc --noEmit && tsx src/index.ts",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"@vtian-dev/ui": "workspace:*",
"@vtian-dev/utils": "workspace:*"
},
"devDependencies": {
"tsx": "^4.19.4"
}
}
apps/web/src/index.ts:
ts
import { renderButton } from '@vtian-dev/ui'
import { greet, add } from '@vtian-dev/utils'
console.log('Button:', renderButton({ label: 'submit', variant: 'primary' }))
console.log('greet:', greet('world'))
console.log('1 + 2 =', add(1, 2))
安装依赖后运行 pnpm build,输出:
ini
Button: <button class="btn btn-primary">Submit</button>
greet: Hello, World!
1 + 2 = 3
至此,一个依赖关系清晰的 Monorepo 就搭好了。
六、Turborepo 核心机制(理解了才能用好)
6.1 任务流水线与 DAG
执行 pnpm build(即 turbo run build)时,Turborepo 会:
- 扫描所有包,找到有
build脚本的包; - 根据
dependsOn: ["^build"]和包间依赖,构建出有向无环图(DAG); - 按拓扑顺序执行,无依赖关系的任务并行跑。
实际执行顺序:
less
@vtian-dev/utils:build
↓
@vtian-dev/ui:build
↓
@vtian-dev/web:build ⟷ @vtian-dev/docs:build (并行)
你不需要手动管顺序,改了 utils,Turborepo 自动先重建 utils → ui → 再重建依赖它们的应用。
6.2 内容寻址缓存
Turborepo 的缓存不是「按文件名」,而是按内容哈希:
- 根据
inputs里的文件内容 + 环境 + 依赖版本,算出一个 hash 作为缓存 key; - 下次执行时,如果 hash 没变,直接还原上次的
outputs产物,跳过整个任务。
效果对比:
bash
# 第一次构建(冷缓存)
Tasks: 4 successful Time: 11.96s
# 第二次构建(无改动,全部命中缓存)
Tasks: 4 successful Cached: 4 cached Time: 59ms >>> FULL TURBO
这就是为什么 Turborepo 在大型 Monorepo 里能做到「秒级构建」。
6.3 常用命令
bash
# 只构建某个包
pnpm turbo run build --filter=@vtian-dev/utils
# 构建某包及其所有依赖
pnpm turbo run build --filter=@vtian-dev/web...
# 构建某包及所有依赖它的包
pnpm turbo run build --filter=...@vtian-dev/utils
# 只构建 git 变更涉及的包
pnpm turbo run build --filter=[HEAD^1]
# 为某个 app 单独添加依赖
pnpm add lodash --filter @vtian-dev/web
七、Changesets:版本管理与发包
7.1 配置
在根目录创建 .changeset/config.json:
json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
关键配置:
| 字段 | 说明 |
|---|---|
access: "public" |
包发布为公开可见(scope 包默认是 private,必须显式声明) |
baseBranch: "main" |
必须和你的 Git 默认分支名一致 |
updateInternalDependencies: "patch" |
内部依赖升级时,自动 bump 依赖它的包(patch 级别) |
7.2 三步走发版流程
第 1 步:记录变更
改完代码后,运行交互式命令,选择要发版的包和版本级别:
bash
pnpm changeset
它会询问:哪些包有变更 → 选版本级别(patch/minor/major)→ 写一段变更描述。最终在 .changeset/ 下生成一个 Markdown 文件(如 xxx.md)。
这个 changeset 文件要随代码一起提交,它是后续版本计算的依据。
第 2 步:汇总变更,升级版本
bash
pnpm version-packages
Changesets 会消费所有 changeset 文件,自动:
- 升级对应包的
package.json版本号; - 生成/更新
CHANGELOG.md; - 处理内部依赖的联动升级(
updateInternalDependencies)。
第 3 步:构建并发布
bash
pnpm publish-packages
实际执行的是 turbo run build && changeset publish,先全量构建,再只发布有变更的包。
7.3 版本级别(Semver)
| 级别 | 版本变化 | 适用场景 |
|---|---|---|
| patch | 0.1.0 → 0.1.1 | 修复 bug |
| minor | 0.1.0 → 0.2.0 | 新增功能(向后兼容) |
| major | 0.1.0 → 1.0.0 | 破坏性变更 |
八、完整开发流程(一图流)
日常开发与发版的标准流程:
bash
# ① 开发阶段:改 utils 源码
# 本地看效果(watch 联动)
pnpm dev
# ② 自测通过后,记录变更
pnpm changeset # 选 @vtian-dev/utils → minor,写描述
# ③ 提交代码(changeset 文件一起提交)
git add .
git commit -m "feat: add formatBytes"
# ④ 合入 main 后,统一发版
pnpm version-packages # utils 0.1.0 → 0.2.0,生成 CHANGELOG
pnpm publish-packages # 构建并发布到 npm
关于「本地开发时其他包如何用上最新的 utils」,答案在 pnpm dev:它会并行启动 utils/ui 的 tsup --watch(源码一变就重出 dist)和 web/docs 的 tsx watch(自动重跑),实现「改一处、处处生效」。
九、实战踩坑记录(重点!)
以下 8 个坑都是我实际踩过并解决的,每一个都排查了不少时间。
坑 1:pnpm changeset init 直接报错
现象 :执行 pnpm changeset init 报错退出(exit code 13),提示模块加载失败。
原因 :@changesets/cli@3.0.0 是纯 ESM 包 ,某些环境下 node 直接加载会因 CommonJS/ESM 冲突报错。
解决 :不依赖 init 命令,手动创建 .changeset/config.json 和 .changeset/README.md 两个文件即可,配置内容见上文第七章。
坑 2:baseBranch 与 Git 分支不一致
现象 :pnpm changeset status 报错,无法正常工作。
原因 :配置里 baseBranch 写的是 main,但仓库默认分支是 master(或反之)。
解决:二者保持一致,二选一:
- 改配置:
baseBranch: "master"; - 改分支:
git branch -m master main。
坑 3:changeset version 假成功(版本号纹丝不动)
现象 :pnpm version-packages 执行成功,但所有包的版本号都没变,也没生成 CHANGELOG。
原因 :所有包的 private: true 导致 Changesets 直接跳过了全部包(它默认只处理可发布的包)。
解决:区分「库包」和「应用」:
- 要发布的库包(utils、ui):移除
private: true,并在.changeset/config.json里设access: "public"; - 不发布的应用(web、docs):保留
private: true。
坑 4:包名不一致导致 import 找不到
现象 :构建时报 Cannot find module '@vtian-dev/ui'。
原因 :package.json 里的 name 字段和源码里的 import 语句用的包名不一致(比如一个是 @demo/ui,一个是 @vtian-dev/ui)。
解决 :全局搜索统一包名。Monorepo 里包名散落在 package.json 的 name、各处的 import、以及 dependencies 里,务必三处一致。
坑 5:tsup --dts 阶段类型报错
现象 :构建时在生成 .d.ts 类型声明阶段报 Type 'number' is not assignable to type 'string'。
原因 :源码里写错了返回类型,比如函数声明返回 string,但某处 return 了一个 number。tsup 用 TypeScript 生成类型声明时会做类型检查,于是报错。
解决 :修正源码的类型错误。这提醒我们------库包写严谨的类型,否则会在发版构建阶段才暴露。
坑 6:发布时 403 Two-factor authentication
现象 :pnpm publish-packages 时报:
vbnet
E403: 403 Forbidden - Two-factor authentication or granular access token
with bypass 2fa enabled is required to publish packages.
原因:npm 官方强制要求发布者必须启用双因素认证(2FA),你的账户还没开。
解决:
- 登录 npm 官网 → Settings → Enable 2FA(勾选 Publishing);
- 终端重新登录:
npm logout→npm login(会要求输 OTP); - 重新发布,每次发布会提示输入 6 位验证码。
坑 7:应用包报 no output files found 警告
现象:构建成功,但日志末尾有:
perl
WARNING no output files found for task @vtian-dev/web#build
原因 :turbo.json 的 build.outputs 声明了 dist/**,但应用(web/docs)的 build 脚本只是 tsc --noEmit && tsx ...,不产出 dist,缓存找不到产物。
解决 :两种思路------要么给应用加真正的打包器(Vite/Next)产出 dist,要么接受这个无害警告。它不影响构建和发布。
坑 8:改了源码,应用却还是旧结果
现象 :改了 utils/src 里的代码,但 web 运行时输出没变化。
原因 :应用通过包名引用的是库的 dist 编译产物 (main/exports 指向 dist/),而不是 src 源码。改了源码但没重新 build,dist 还是旧的。
解决:
- 开发时用
pnpm dev(watch 自动重出 dist); - 或者手动先
pnpm --filter @vtian-dev/utils build再跑应用。
总结
本文从 0 到 1 搭建了一个 pnpm + Turborepo + Changesets 的 Monorepo 工程,核心要点回顾:
- pnpm :用
workspace:*协议实现包间软链引用,-w装根依赖,--filter精准操作子包; - Turborepo :
turbo.json定义任务流水线,^build自动推断依赖顺序,内容寻址缓存带来秒级构建; - Changesets :
changeset(记录)→version(升级)→publish(发布)三步走,精细控制版本并自动生成 CHANGELOG。
这套组合是目前前端 Monorepo 最主流、最成熟的最佳实践之一,Vercel、Turborepo 官方示例、大量中大型团队都在用。
最后,把 8 个坑浓缩成一句话提醒:注意 ESM 兼容、分支名、private 字段、包名统一、类型正确、2FA、outputs 配置、以及「应用引用的是 dist 不是 src」------避开这些,你的 Monorepo 之路会顺畅很多。
如果你在搭建过程中遇到其他问题,欢迎在评论区交流。
附:本文完整示例代码对应的依赖关系图
less
┌──────────────────┐
│ @vtian-dev/utils │ (packages/utils)
└────────┬─────────┘
│ workspace:*
┌────────▼─────────┐
│ @vtian-dev/ui │ (packages/ui)
└────────┬─────────┘
workspace:* │ workspace:*
┌─────────────────┼─────────────────┐
▼ │ ▼
┌──────────────────┐ │ ┌──────────────────┐
│ @vtian-dev/web │◄───────┘ │ @vtian-dev/docs │ (apps/*)
└──────────────────┘ └──────────────────┘