Monorepo 从 0 到 1 实操指南 2026 版:pnpm catalogs + Turborepo 2.x + changesets 全链路

上一篇 blog192 讲了 monorepo 选型背后的组织问题,这一篇专心讲落地。

网上 monorepo 教程的通病有三个:

  1. 版本过时 ------大量教程还在写 Turborepo 1.x 的 pipeline 字段,2.x 早就改成 tasks
  2. 拼凑感强------写 pnpm workspaces 不讲 catalogs,写 Turborepo 不讲发布,写 changesets 不讲 OIDC
  3. 没踩过实战的坑------只贴 happy path 命令,不讲哪些配置在 CI 上会爆

这一篇按真实搭建顺序走一遍,从 pnpm init 到 GitHub Actions 自动发布全链路,每个环节顺带告诉你 2026 年正确的做法和最常踩的坑。

0. 前置:技术栈选定

不浪费篇幅讨论选型(去看上一篇)。这里给出一套2026 年默认推荐组合,适用于"3-15 人前端/全栈团队 + 5-20 个内部 package"这个最常见的区间:

维度 选择 理由
包管理器 pnpm 11.x(catalogs 9.5 引入、10.12 起新增 catalogMode 严格模式) 比 npm 快 2-3 倍,workspaces 实现最干净,catalogs 解决版本漂移
任务编排 Turborepo 2.x 配置最简,远程缓存免费,Vercel 维护
版本/发布 changesets 唯一能在 monorepo 下兼顾"独立版本 + 自动发布"的方案
远程缓存 Vercel Remote Cache(免费)→ 不够再换 ducktors 自建 90% 团队不需要自建
CI GitHub Actions 与 changesets + OIDC trusted publishing 集成最顺

如果你团队规模超过 50 人或有 Java/Go 这类多语言栈,再考虑 Nx 或 Bazel。本篇不覆盖。

1. 初始化仓库

bash 复制代码
mkdir my-monorepo && cd my-monorepo
git init
pnpm init

修改根 package.json,加 "private": true 阻止意外发布:

json 复制代码
{
  "name": "my-monorepo",
  "private": true,
  "packageManager": "pnpm@11.8.0"
}

packageManager 字段必须写------这是 Node Corepack 识别的关键字段,确保团队成员和 CI 用同一个 pnpm 版本。版本号写死,不要用 ^latest

创建 pnpm-workspace.yaml

yaml 复制代码
packages:
  - "apps/*"
  - "packages/*"

约定俗成的分层:apps/ 放可部署产物(web/api/cli),packages/ 放被复用的库(ui/utils/types/config)。

2. pnpm catalogs:杜绝版本漂移

catalogs 在 pnpm 9.5(2024 年 7 月)正式发布,10.12 起新增 catalogMode 严格模式,到 2026 年生态适配成熟。少有教程提到,但能解决 monorepo 最常见的痛点------同一个依赖在不同 package 写不同版本,最后 type 不一致或运行时崩。

pnpm-workspace.yaml 加:

yaml 复制代码
packages:
  - "apps/*"
  - "packages/*"

catalog:
  react: ^19.2.0
  react-dom: ^19.2.0
  typescript: ^5.6.3
  vitest: ^3.0.5

catalogs:
  react18:
    react: ^18.3.1
    react-dom: ^18.3.1

子 package 引用方式:

json 复制代码
{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  },
  "devDependencies": {
    "typescript": "catalog:",
    "vitest": "catalog:"
  }
}

对于有些 package 必须停留在旧版本的情况(比如有个 React 18 的遗留 app),用命名 catalog:

json 复制代码
{
  "dependencies": {
    "react": "catalog:react18"
  }
}

升级依赖只改 pnpm-workspace.yaml 一处,跑 pnpm update -r 同步所有 package。

catalogMode :在 pnpm-workspace.yamlcatalogMode: strict 强制所有依赖必须走 catalog,禁止子 package 写裸版本号。团队规模一上来就开严格模式,否则 catalog 形同虚设。

3. 内部包互相引用:workspace 协议

bash 复制代码
mkdir -p packages/ui apps/web
cd packages/ui && pnpm init

packages/ui/package.json

json 复制代码
{
  "name": "@my/ui",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}

apps/web/package.json 引用它:

json 复制代码
{
  "dependencies": {
    "@my/ui": "workspace:*"
  }
}

workspace:* 是 pnpm/yarn 都支持的协议,告诉包管理器**"必须从本仓库链接"**,避免不小心从 npm 拉了同名包。pnpm installnode_modules/@my/ui 是 symlink,源代码改了立即生效,零构建延迟。

发布时 pnpm publish 会自动把 workspace:* 替换成具体版本号写入发布产物(yarn 同理),下游消费者拿到的是正常的 npm 依赖。changesets 只负责 bump 版本和触发 publish,转换工作由包管理器完成。

4. Turborepo 2.x:任务编排

bash 复制代码
pnpm add -Dw turbo

-Dw = devDependency + workspace root。Turborepo 只装在根。

创建 turbo.json

json 复制代码
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
      "inputs": ["$TURBO_DEFAULT$", ".env*"]
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "dependsOn": ["^build"]
    },
    "typecheck": {
      "dependsOn": ["^build"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

关键字段逐个拆

  • tasks :2.x 改名,1.x 时叫 pipeline。如果你在网上抄到 pipeline 直接报错,需要替换
  • dependsOn: ["^build"]^ 前缀 = 先跑依赖包的同名 task。例 web 依赖 ui,跑 turbo build 时会先 build ui 再 build web
  • outputs :哪些产物要被缓存。漏掉这个等于每次都重跑,是新手最大坑
  • !.next/cache/**:排除 Next.js 自己的 cache 目录,避免缓存里塞缓存
  • inputs :哪些文件变化会让 cache 失效。$TURBO_DEFAULT$ 是 2.x 引入的便捷宏,代表"该 package 的所有源文件"
  • persistent: true:dev server 类长任务必须加,告诉 Turbo "这个 task 不会自己退出,别等"

注解 :示例里 lint / typecheck 都加了 dependsOn: ["^build"],前提是你的 package 之间通过 dist 产物共享 types。如果各 package 直接 import 源码 .ts(很多前端 monorepo 都这样配),把这两个 task 的 dependsOn 去掉,CI 会快很多。

跑任务:

bash 复制代码
turbo run build              # 所有 package
turbo run build --filter=@my/web   # 只构 web(包含依赖)
turbo run test --filter='[HEAD^]'  # 只测改动影响到的 package

第三种是 monorepo 的 affected 模式------CI 上只跑变动相关的 package,是 monorepo 比 polyrepo 优秀的核心场景。

5. 远程缓存:先用 Vercel 免费版

跑一次:

bash 复制代码
npx turbo login
npx turbo link

login 把你的机器和 Vercel 账号绑定,link 把当前仓库链接到 Vercel 远程缓存。Vercel 远程缓存对所有人免费,不强制把应用部署到 Vercel------这是很多教程没说清楚的点。

CI 上要让 GitHub Actions 也能命中缓存,需要两个环境变量:

yaml 复制代码
env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}

TURBO_TOKENvercel.com/account/tok... 生成,命名建议 ci-monorepo-cache,权限选 read+writeTURBO_TEAM 是你 Vercel 团队的 slug(个人账号就是用户名)。

什么时候要自建? 三个信号同时出现:① 月构建任务量 > 5000 次 ② 公司合规要求 build artifact 不离自家 VPC ③ 跨地域 CI 集群(参考上一篇 Mercari 案例)。否则直接用 Vercel,省心。

自建首选 ducktors/turborepo-remote-cache,是社区维护的开源实现,支持 S3/GCS/Azure Blob 多后端,Docker 一行起。

6. changesets:版本与发布

bash 复制代码
pnpm add -Dw @changesets/cli
pnpm changeset init

init 会在根目录创建 .changeset/config.json,把 access 改成 public(发布到 npm 公开 registry)或 restricted(私有):

json 复制代码
{
  "$schema": "https://unpkg.com/@changesets/config@3/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": []
}

逐字段说明

  • fixed :哪些包必须同步发版(共用大版本号)。例如 React 体系会把 react react-domfixed
  • linked:哪些包共用 minor/patch 号但可独立发布。一般用不到
  • updateInternalDependencies :内部包之间相互引用的更新策略。patch 是最常见------A 依赖 B,B 发了 minor,A 自动跟一个 patch
  • ignore:放在这里的 package 不会被 changesets 管理(常用于 example、playground)

日常工作流

bash 复制代码
# 1. 改完代码、写完 commit 之前,添加一个 changeset
pnpm changeset

# 交互式选:哪些 package 改了 → major/minor/patch → 改动说明
# 生成一个 .changeset/foo-bar.md

# 2. 提交时把 changeset 文件一起 commit
git add . && git commit -m "feat(ui): add Button variant"

PR 合并到 main 后,CI 上的 changesets-action 会自动开一个 Version Packages PR,把所有未发布的 changeset 合并、bump 版本、改 CHANGELOG。这个 PR 合并的瞬间,包就发到 npm。

7. CI 全流程:GitHub Actions

.github/workflows/ci.yml

yaml 复制代码
name: CI
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
      TURBO_TEAM: ${{ vars.TURBO_TEAM }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 必须,turbo affected 需要 git history

      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22.14
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run lint typecheck test build

关键点

  • fetch-depth: 0 :默认 actions/checkout 只拉 1 个 commit,turbo --filter='[HEAD^]' 算不出 affected,必须拉全 history
  • --frozen-lockfile:CI 必须用,本地 lockfile 和实际 install 不一致直接报错
  • 任务串成一行:Turborepo 会自动并行调度有依赖关系的 task,串成一行 vs 写多个 step 性能一样但日志更清

.github/workflows/release.yml(发布工作流):

yaml 复制代码
name: Release
on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
      id-token: write   # OIDC trusted publishing
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22.14
          cache: pnpm
          registry-url: https://registry.npmjs.org

      - run: pnpm install --frozen-lockfile
      - run: pnpm turbo run build

      - uses: changesets/action@v1
        with:
          publish: pnpm changeset publish
          version: pnpm changeset version
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

注意 id-token: write :npm OIDC trusted publishing 在 2025 年 7 月 31 日 GA,2025 年 12 月 9 日起 classic token 永久弃用。它替代了传统的长寿命 NPM_TOKEN,避免 token 泄露要手动 rotate。前提是你在 npm 包页面配置好 trusted publisher,绑定到你的 GitHub repo + workflow 文件名。

硬性要求 :OIDC 发布需要 npm CLI ≥ 11.5.1 + Node ≥ 22.14.0 ,所以上面 workflow 里写的是 node-version: 22.14(不要再写裸 22)。

设置完后 workflow 不再需要 NPM_TOKEN secret ------changesets/action 会自动找 OIDC token 完成发布认证。

8. CODEOWNERS:所有权强制

monorepo 一上规模,必须有 CODEOWNERS 文件强制 review 路由。.github/CODEOWNERS

perl 复制代码
# 默认 owner
* @alice

# 按目录
/packages/ui/        @ui-team
/packages/utils/     @platform-team
/apps/web/           @web-team
/apps/api/           @backend-team

# 跨团队配置
/turbo.json          @platform-team @ui-team
/.changeset/         @release-managers
/.github/workflows/  @devops

打开仓库 Settings → Branches → Branch protection rules,勾上 "Require review from Code Owners"。从此跨域改动必须对应 owner 批准才能 merge。

这一步看似简单但至关重要------上一篇里讲的"50 人团队的所有权失效"问题,CODEOWNERS 是底层防线。没这层强制,monorepo 一年内就会变成"谁都能改谁都不负责"的烂泥地。

9. 五个最常踩的坑

实战经验,网上教程基本不会告诉你这些

坑 1:Turborepo cache 没命中,原因是 inputs 没配 默认 Turborepo 把 package 下所有文件当 cache key,但它不知道环境变量 。如果你的 build 读 process.env.NODE_ENV,在 dev/prod 间切换不会重 build。修复:

json 复制代码
"build": {
  "env": ["NODE_ENV", "VITE_*"]
}

VITE_* 是 2.x 引入的通配符语法,匹配前缀。

坑 2:pnpm 幽灵依赖(phantom dependency) pnpm 默认严格 hoist,子 package 不能 import 没在自己 package.json 声明的依赖。但 monorepo 里很常见有人写:

ts 复制代码
// packages/web/src/foo.ts
import { format } from 'date-fns'   // ❌ web 没声明 date-fns

ts 不报错,是因为 root 装了 date-fns。dev 跑得好好的,发布后用户安装时炸。强制开启 pnpm 严格模式 ,在根 .npmrc

ini 复制代码
public-hoist-pattern[]=
shamefully-hoist=false

坑 3:changeset 漏写,CI 跳过发布 有人改了源码忘加 changeset,CI 不会报错只会跳过发布,bug 在用户那爆。强制 PR 必须有 changeset,加一个 check workflow:

yaml 复制代码
- run: pnpm changeset status --since=origin/main

status 命令如果发现有 src 改动但没对应 changeset 会 exit 1,CI 直接红。

坑 4:turbo remote cache 在 PR fork 里失效 Vercel 远程缓存出于安全考虑,默认禁止 PR fork 写缓存 (只读)。开源项目跑得慢就是这个原因。修复用 Turborepo 提供的真实环境变量 TURBO_REMOTE_CACHE_READ_ONLY 控制是否只读,或者在 turbo run 命令上加 --cache=remote:r(只读)/ --cache=remote:rw(读写)flag:

yaml 复制代码
# 在 fork PR 上强制只读,避免污染主仓库缓存
- name: Build
  run: pnpm turbo run build --cache=${{ github.event.pull_request.head.repo.full_name != github.repository && 'remote:r' || 'remote:rw' }}

--cache=remote:r / remote:rw 是 Turborepo 2.x 真实的命令行 flag;不要被网上一些教程里的 TURBO_CACHE 环境变量误导 ------Turborepo 至今没有这个环境变量,只有 TURBO_CACHE_DIR(指定缓存目录,是另一回事)。

坑 5:内部 package 用 .ts 直接 import,发布出去消费者炸 开发期 main: ./src/index.ts 很爽,热重载零延迟。发布出去 npm 用户没 ts runtime,import 直接报错。双导出策略

json 复制代码
{
  "exports": {
    ".": {
      "development": "./src/index.ts",
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "publishConfig": {
    "exports": {
      ".": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
    }
  }
}

publishConfig.exports发布时覆盖 顶层 exports,把 development 路径去掉。但有两点必须注意,否则反而被坑:

  1. 这是 pnpm 和 yarn 的能力,npm 官方 CLI 至今不支持npm/cli#7586 仍未合并)。如果你用 npm publish 发包,publishConfig.exports 不会生效,发出去的还是 development 路径,下游消费者直接炸。必须用 pnpm publishyarn npm publish ------好在 changesets 工作流里把 publish 命令显式写成 pnpm changeset publish 就能正确走 pnpm 通道。
  2. development condition 不是所有 runtime 都识别 ------Vite、esbuild、tsx 支持,Node 原生 require/import 在 24 之前不识别。如果你 monorepo 里有纯 Node 跑的 app 直接消费源码 TS,需要配合 tsx/ts-node 这类 loader。

10. 最小目录结构总览

跑完上面所有步骤,仓库长这样:

csharp 复制代码
my-monorepo/
├── .changeset/
│   └── config.json
├── .github/
│   ├── CODEOWNERS
│   └── workflows/
│       ├── ci.yml
│       └── release.yml
├── apps/
│   ├── web/        # Next.js / Vite app
│   └── api/        # Hono / Express
├── packages/
│   ├── ui/         # 组件库
│   ├── utils/      # 共享工具
│   ├── types/      # 共享类型
│   └── config/     # 共享 ESLint / tsconfig
├── .npmrc
├── package.json
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
└── turbo.json

packages/config 这一个 package 单独说一下------把 ESLint / tsconfig / Prettier 配置都放在它里面,对外 export,所有 app 和 package 引用:

json 复制代码
{
  "extends": "@my/config/tsconfig/base.json"
}

避免每个子 package 都自己抄一份配置然后慢慢飘逸。

结尾

上面这套是 2026 年我会实际给团队推荐的默认组合。它不是"最酷"的搭配(没用 Nx 那种全家桶、没上 Bazel),但每个选择都经得起 3 年时间考验

  • pnpm catalogs 解决版本漂移,2026 是它从实验变成生产可用的年份
  • Turborepo 2.x tasks 字段稳定,下一次 breaking change 至少 18 个月后
  • changesets + OIDC 已经是 npm 生态默认的发布范式
  • CODEOWNERS 是 GitHub 平台原生能力,不依赖任何工具链

把上一篇的判断框架(什么时候上 monorepo)和这一篇的实操路径(怎么搭一个生产级 monorepo)合起来看,应该够覆盖 90% 团队的 monorepo 决策与实施。剩下 10% 是 Bazel/Buck 这种超大规模场景,那个需要专门的 build infra 团队,不在通用建议范围。


延伸阅读


原文链接chenguangliang.com/posts/blog1...

相关推荐
子兮曰1 小时前
OpenMontage 深度解剖:你的 AI 编程助手,其实是个视频工作室
前端·后端·ai编程
敲代码的鱼1 小时前
PDF 预览与签名批注写回 支持安卓 iOS 鸿蒙 UTS插件
android·前端·ios
子兮曰1 小时前
前端工具链的「Rust 化」:一场没有赢家的军备竞赛?
前端·后端·rust
Hyyy2 小时前
Function Calling / Tool Use的原理和实现模式
前端·llm·ai编程
爱勇宝3 小时前
从 Ctrl+CV 到 Enter:程序员正在失去什么
前端·后端·程序员
徐小夕3 小时前
我们开源了一款“框架无关”的思维导图编辑器,3分钟集成到任意系统
前端·javascript·github
PBitW3 小时前
GPT训练我的第三天,明白了应该咋说满分回答!😕😕😕
前端·javascript·面试
摸着石头过河的石头3 小时前
前端多仓库管理:从混乱到有序的进化之路
前端
星栈3 小时前
写 Dioxus Demo 不难,难的是把它写成项目
前端·rust·前端框架