一篇文章吃透 Monorepo:pnpm + Turborepo + Changesets 全流程实战(含 8 个踩坑)

前言

随着前端工程越来越复杂,一个项目往往要同时维护多个应用(web 端、文档站、后台管理)和多个共享库(UI 组件库、工具函数库、业务逻辑库)。传统的「一仓一包」模式会带来三个痛点:

  1. 代码重复:多个项目各自复制一份工具函数,改一处要同步 N 处;
  2. 联调困难 :改个组件库,要手动 npm link 或发个临时包才能在其他项目里验证;
  3. 版本混乱:多个包独立发版,版本号、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#buildui#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 会:

  1. 扫描所有包,找到有 build 脚本的包;
  2. 根据 dependsOn: ["^build"] 和包间依赖,构建出有向无环图(DAG)
  3. 按拓扑顺序执行,无依赖关系的任务并行跑。

实际执行顺序:

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/uitsup --watch(源码一变就重出 dist)和 web/docstsx 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.jsonname、各处的 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),你的账户还没开。

解决

  1. 登录 npm 官网 → Settings → Enable 2FA(勾选 Publishing);
  2. 终端重新登录:npm logoutnpm login(会要求输 OTP);
  3. 重新发布,每次发布会提示输入 6 位验证码。

坑 7:应用包报 no output files found 警告

现象:构建成功,但日志末尾有:

perl 复制代码
WARNING  no output files found for task @vtian-dev/web#build

原因turbo.jsonbuild.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 精准操作子包;
  • Turborepoturbo.json 定义任务流水线,^build 自动推断依赖顺序,内容寻址缓存带来秒级构建;
  • Changesetschangeset(记录)→ 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/*)
 └──────────────────┘                 └──────────────────┘
相关推荐
默_笙1 小时前
❗ 点击计数按钮,为什么"峨眉队"也跟着重新渲染?React.memo 说:我记住了
前端·javascript
31535669132 小时前
DeepSeek Harness 发布后,我没急着跑 Demo,先把 `.agents/` 翻了一遍
前端·后端·github
名字还没想好☜2 小时前
Next.js 用 Server Components 直连数据库:去掉 API 层的边界,和三条别踩的安全红线
前端·javascript·数据库·安全·react·next.js
学习zhao极致it2 小时前
2020全新React教程全家桶实战redux+antd+React Hooks前端js视频
前端
用户921080262862 小时前
Bubble 消息操作区改造:复制、重新生成和反馈
前端
LEE2 小时前
AI Agent 都在疯狂加功能,它说:我全砍了
前端·后端
张清悠2 小时前
用 TRAE Work 把 PRD + 设计稿注释,整理成 20 分钟可排期前端任务清单
前端
Brown.alexis3 小时前
es6知识点5-自备使用
前端·javascript·es6
杨利杰YJlio3 小时前
KB5121003更新详解:安全启动证书、AI组件、资源管理器与安装验证
前端·javascript·后端