前端工程化 / 闯关模式

pnpm + monorepo + Turborepo 管理多项目

理解 monorepo、pnpm workspace、workspace:*、--filter、Turborepo 任务调度和缓存。

一句话:monorepo 解决“多个项目怎么放在一起”,pnpm workspace 解决“这些项目怎么共享本地包”,Turborepo 解决“这些项目的 build、lint、typecheck 怎么更快、更有顺序地跑”。

本篇学完你会什么:能分清 pnpm、monorepo、Turborepo 各自负责什么,知道它们是不是必须一起用,也能判断一个已有项目该不该迁移成根级 workspace 和 Turbo 管理。

1. 先回答最容易混淆的 3 个问题

问题 1:pnpm、monorepo、Turborepo 必须一起用吗

不必须。

它们是三层东西:

层级名字负责什么
组织方式monorepo多个项目放在一个 Git 仓库里
包管理工具pnpm workspace安装依赖、本地包互相引用
任务调度工具Turborepo跑 build、dev、lint、typecheck,并做缓存

所以可以有这些组合:

组合可以吗适合场景
只用 pnpm可以单个项目
pnpm + workspace可以多个项目要共享本地包
pnpm + workspace + Turborepo可以项目多了,构建和检查任务开始变慢
Turborepo 单独用不推荐它最强的能力依赖 workspace 关系

先记住一句话:

pnpm 管依赖。
monorepo 管项目放在哪里。
Turborepo 管任务怎么跑。

问题 2:多个项目不在同一个仓库,Turborepo 怎么工作

不好工作。

Turborepo 最擅长的是这种结构:

one-repo/
  apps/admin/
  apps/website/
  packages/utils/
  pnpm-workspace.yaml
  turbo.json

它能在同一个仓库里看到:

  • 哪些项目存在
  • 哪些包互相依赖
  • 哪些任务要先跑
  • 哪些任务可以跳过

如果项目分散在多个仓库:

repo-admin/
repo-website/
repo-utils/

Turbo 就很难自然知道这些仓库之间的关系。你可以写脚本强行调度,但那更像是在用 shell 或 CI 编排多个仓库,不是 Turborepo 最顺手的用法。

问题 3:我直接把多个项目放一个文件夹,不就行了吗

可以,但那只是“放得近”,不一定是 monorepo。

比如:

projects/
  admin/
  website/
  ai/

如果每个项目都有自己的:

  • node_modules
  • pnpm-lock.yaml
  • 独立安装命令
  • 独立公共代码副本

那它们只是住在同一个大文件夹里,彼此并没有真正建立关系。

真正有用的 monorepo 通常还会有:

root/
  apps/
  packages/
  package.json
  pnpm-workspace.yaml
  pnpm-lock.yaml

它不只是“放一起”,而是能做到:

  • 统一安装依赖
  • 共享本地包
  • 统一脚本命令
  • 一次提交同时改多个相关项目
  • 后续可以接入 Turborepo 做任务缓存

2. 三个词分别是什么

可以用一个“公司办公室”的类比:

monorepo = 一栋办公楼
pnpm workspace = 楼里的通讯录和仓库钥匙
Turborepo = 楼里的任务调度员

办公楼让不同团队在一个地方办公。

通讯录和仓库钥匙让大家能找到彼此,也能共享内部工具。

任务调度员负责安排工作顺序:谁先做,谁后做,谁这次不用重复做。

monorepo pnpm workspace Turborepo 职责关系图

再用表格压缩一下:

名字它是什么它不是什么
monorepo仓库组织方式不是 npm 包,也不是构建工具
pnpm workspacepnpm 的多包管理能力不是任务缓存工具
Turborepo任务编排和缓存工具不是包管理器

3. 只把项目放进一个文件夹,为什么还不算真正的 monorepo

假设你现在有 3 个前端:

frontend-admin/
frontend-ai/
frontend-website/

如果它们只是这样放在同一个目录下:

zero-one/
  frontend-admin/
    package.json
    pnpm-lock.yaml
  frontend-ai/
    package.json
    pnpm-lock.yaml
  frontend-website/
    package.json
    pnpm-lock.yaml

好处只有一个:你找文件方便。

但它们之间没有真正的“项目关系”:

  • frontend-admin 不知道 frontend-ai 存在
  • 公共工具函数不能自然共享
  • 每个项目可能安装一套自己的依赖版本
  • 根目录不能统一跑所有项目的检查
  • 后续 Turbo 也没有完整任务图可以用

monorepo 的重点不是“文件夹挨着”,而是“仓库里有统一规则”。

比如:

zero-one/
  apps/
    admin/
    ai/
    website/
  packages/
    utils/
    request/
    ui/
  package.json
  pnpm-workspace.yaml
  pnpm-lock.yaml

这时根目录的 pnpm-workspace.yaml 会告诉 pnpm:

packages:
  - "apps/*"
  - "packages/*"

意思是:

apps/ 下面都是应用。
packages/ 下面都是共享包。
这些目录共同组成一个 workspace。

这才开始进入“真正可管理的 monorepo”。

4. pnpm workspace 到底帮你做了什么

pnpm 本身是包管理器,和 npm、yarn 属于同一类工具。

它最基础的工作是:

读取 package.json
安装 dependencies
生成 pnpm-lock.yaml
让项目可以 import 第三方包

加上 workspace 之后,它多了一个能力:让本地多个 package 互相引用。

比如你有一个公共工具包:

packages/utils/
  package.json
  src/index.ts

packages/utils/package.json

{
  "name": "@zero-one/utils",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}

然后 apps/website/package.json 可以这样写:

{
  "dependencies": {
    "@zero-one/utils": "workspace:*"
  }
}

workspace:* 的意思是:

不要去 npm 远程仓库找 @zero-one/utils。
直接使用当前 workspace 里的 packages/utils。

业务代码里就可以像用普通 npm 包一样使用它:

import { formatDate } from "@zero-one/utils";

const text = formatDate(new Date());

这就是 pnpm workspace 的核心价值:

共享代码不用复制。
共享代码也不用先发布到 npm。
本地改了共享包,业务项目能直接用。

pnpm workspace 本地包依赖关系图

5. Turborepo 到底帮你做了什么

pnpm workspace 管的是“包之间怎么引用”。

但是项目多了以后,你还会遇到另一个问题:命令怎么跑才聪明。

比如你有:

apps/admin       # 后台
apps/website     # 官网
packages/utils   # 公共工具包
packages/ui      # 公共 UI 包

依赖关系是:

admin   -> ui -> utils
website -> ui -> utils

现在你执行:

pnpm build

真正的问题不是“能不能跑”,而是:

  • 应该先 build utils 还是先 build admin
  • adminwebsite 能不能并行 build
  • 如果 utils 没变,能不能跳过它
  • 如果只改了 website 的页面,能不能不重新 build admin
  • CI 每次都全量构建会不会太慢

这些就是 Turborepo 负责的。

它会把任务看成一张图:

packages/utils build
        ↓
packages/ui build
        ↓
apps/admin build
apps/website build

然后它会尽量做到:

能力大白话解释
任务排序先跑被依赖的包,再跑依赖它的应用
并行执行互不影响的任务同时跑
本地缓存文件没变,重复任务可以跳过
远程缓存CI 或同事电脑也能复用缓存,配置后才有
过滤执行只跑某个项目,或者某个项目加它依赖的包

所以 Turborepo 的核心不是“共享代码”。

共享代码是 pnpm workspace 的事情。

Turborepo 的核心是:

同一个仓库里有很多任务。
我帮你判断哪些任务要跑、按什么顺序跑、哪些可以跳过。

Turborepo 缓存和任务调度流程图

6. pnpm workspace 和 Turborepo 的区别

这一节很重要。

很多人一开始会把它们混在一起,以为用了 monorepo 就必须马上用 Turbo。

其实不是。

问题pnpm workspaceTurborepo
安装依赖负责不负责
本地包引用负责不负责
workspace:*负责不负责
统一 lockfile负责不负责
任务按依赖顺序执行能做一部分,但不够专门负责
build 缓存不负责负责
只跑受影响任务不擅长擅长
CI 加速有限擅长

更直观一点:

pnpm install

这句话主要是 pnpm 的世界。

pnpm --filter website dev

这句话还是 pnpm 的世界,它在筛选某个 workspace package。

pnpm turbo build

这句话进入 Turbo 的世界,它在调度 build 任务。

如果你的项目现在只有 2 个应用,公共包也很少,先用 pnpm workspace 就够了。

当你开始感觉:

  • 每次 build 都要等很久
  • 不知道改了公共包后该重建哪些项目
  • CI 总是全量跑
  • linttypecheckbuild 越来越多

这时再加 Turborepo,收益会更明显。

7. 从一个最小例子看完整工作流

先创建一个根目录:

mkdir my-monorepo
cd my-monorepo
pnpm init

创建目录:

my-monorepo/
  apps/
    website/
    admin/
  packages/
    utils/

创建 pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"

创建 packages/utils/package.json

{
  "name": "@my-project/utils",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}

创建 packages/utils/src/index.ts

export function formatTitle(title: string) {
  return title.trim().replace(/\s+/g, " ");
}

apps/website/package.json 引用它:

{
  "name": "website",
  "private": true,
  "scripts": {
    "dev": "vite --host 0.0.0.0 --port 3000",
    "build": "vite build",
    "lint": "eslint .",
    "typecheck": "vue-tsc --noEmit"
  },
  "dependencies": {
    "@my-project/utils": "workspace:*"
  }
}

安装依赖:

pnpm install

只启动官网:

pnpm --filter website dev

对所有 workspace 包执行 build:

pnpm -r build

-r 是 recursive,也就是递归执行所有 workspace package 里的同名脚本。

到这里为止,你还没有用 Turborepo,但已经是一个能共享本地包的 pnpm workspace 了。

接下来才加 Turbo:

pnpm add -D turbo -w

-w 的意思是安装到 workspace 根目录。

创建 turbo.json

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".output/**", ".nuxt/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "typecheck": {}
  }
}

这几个配置的意思:

配置意思
build定义 build 任务
dependsOn: ["^build"]先构建当前包依赖的其他包
outputs告诉 Turbo 哪些文件是构建产物,可以缓存
dev.cache: false开发服务不要缓存
dev.persistent: truedev 是长期运行的任务

根目录 package.json 可以写:

{
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "lint": "turbo lint",
    "typecheck": "turbo typecheck"
  },
  "devDependencies": {
    "turbo": "^2.0.0"
  }
}

以后你就可以在根目录跑:

pnpm build
pnpm lint
pnpm typecheck

只构建官网:

pnpm turbo build --filter=website

构建官网以及它依赖的包:

pnpm turbo build --filter=website...

这里的 ... 可以理解为“把相关依赖也带上”。

8. 推荐目录结构

如果从干净项目开始,推荐这样组织:

my-monorepo/
  apps/
    website/
      package.json
    admin/
      package.json
    ai/
      package.json
  packages/
    utils/
      package.json
    request/
      package.json
    ui/
      package.json
  package.json
  pnpm-workspace.yaml
  pnpm-lock.yaml
  turbo.json

每一层职责:

目录或文件放什么
apps/真正能运行、能部署的应用,比如官网、后台、AI 前端
packages/被多个应用引用的共享包,比如工具函数、请求封装、UI 组件
package.json统一脚本和公共开发依赖
pnpm-workspace.yaml告诉 pnpm 哪些目录属于 workspace
pnpm-lock.yaml锁定整个 workspace 的依赖版本
turbo.json告诉 Turbo 怎么调度任务和缓存产物

不要一开始就把所有东西抽成包。

更稳的做法是:

先放进 apps/
再统一 workspace
观察重复代码
稳定复用后再抽 packages/
任务变多后再接 Turbo

9. 用这个项目理解迁移路线

以当前 zero-one 项目为例,仓库里已经有多个前端和后端:

zero-one/
  frontend-website/     # 公开网站项目,也就是 /learn 展示文章的项目
  frontend-zero-ai/     # AI 应用前端项目
  frontend-zero-bi/     # BI 数据分析前端项目
  frontend-react/       # React 版本前端项目或实验项目
  frontend-vue/         # Vue 管理端项目
  backend/              # 后端服务项目

它已经有“多个项目在一个仓库里”的形态。

但目前更像是:

多个项目放在同一个仓库下,
每个前端项目自己管理一套 pnpm workspace。

如果要整理成根级 workspace,不建议一口气大改。

第一步:只建立根级 workspace

先在根目录新增 pnpm-workspace.yaml

packages:
  - "frontend-website"
  - "frontend-zero-ai"
  - "frontend-zero-bi"
  - "frontend-react"
  - "frontend-vue"

这个阶段只做一件事:让 pnpm 知道这些前端项目属于同一个 workspace。

不要急着抽包,也不要急着加 Turbo。

第二步:统一 package name 和 scripts

每个前端项目的 package.json 都要有稳定名字:

{
  "name": "frontend-website",
  "private": true,
  "scripts": {
    "dev": "...",
    "build": "...",
    "typecheck": "..."
  }
}

脚本名字尽量统一:

脚本作用
dev本地启动
build生产构建
build:test测试环境构建
typecheck类型检查
lint代码检查

脚本名统一之后,后续 Turbo 才好调度。

第三步:抽真正重复的工具包

比如多个前端项目都需要:

  • 时间格式化
  • API 响应处理
  • 权限判断
  • 常量枚举
  • Markdown 渲染工具

就可以抽:

packages/
  shared/
  utils/
  request/

判断标准很简单:

至少两个项目稳定复用,
而且复制维护已经开始烦了,
再抽成 package。

第四步:谨慎抽 UI 包

UI 包最容易抽过头。

推荐先抽这些低业务属性组件:

  • 按钮
  • 表单项
  • 空状态
  • 弹窗
  • 页面标题
  • 数据卡片

不要一开始就抽复杂业务组件。

公共 UI 包可以叫:

packages/ui/

业务项目引用:

{
  "dependencies": {
    "@zero-one/ui": "workspace:*"
  }
}

第五步:最后接入 Turborepo

等 workspace 跑顺了,再加 turbo.json

原因很简单:

Turborepo 是加速器,不是地基。

地基是:

目录清楚
项目名清楚
依赖关系清楚
脚本命令清楚

这些没整理好,直接上 Turborepo 只会让问题更难排查。

现有项目迁移到 pnpm workspace 和 Turborepo 的步骤图

10. 常见错误和排查

错误 1:把 monorepo 当成工具

monorepo 不是工具。

它是“多个项目放进同一个 Git 仓库”的组织方式。

你可以不用 Turborepo,也能有 monorepo。

错误 2:以为用了 pnpm workspace 就必须用 Turborepo

不必须。

如果项目还小,可以先只用 pnpm workspace。

当你开始遇到这些问题时,再考虑 Turborepo:

  • build 越来越慢
  • 多个项目任务要按依赖顺序跑
  • 想复用上次构建缓存
  • 想在 CI 里只跑受影响项目

错误 3:以为放一个文件夹就是 monorepo

只是放在一个文件夹里,最多叫“目录集中”。

真正有管理价值的 monorepo 至少要能回答这些问题:

  • 哪些目录属于 workspace
  • 哪些项目是 app
  • 哪些项目是 package
  • 公共代码怎么被本地引用
  • 根目录能不能统一安装和执行命令

错误 4:共享包忘记使用 workspace 协议

错误写法:

{
  "dependencies": {
    "@zero-one/utils": "^1.0.0"
  }
}

这会让包管理器去远程 npm 找。

本地 workspace 应该写:

{
  "dependencies": {
    "@zero-one/utils": "workspace:*"
  }
}

错误 5:turbo build 没有配置 outputs

如果 outputs 配错,Turborepo 不知道哪些文件是构建产物,缓存效果会变差。

常见前端产物:

{
  "outputs": ["dist/**", ".output/**", ".nuxt/**"]
}

不同框架产物不一样:

框架常见产物
Vitedist/**
Nuxt.output/, .nuxt/
Next.js.next/**

错误 6:一开始就抽太多共享包

共享包不是越多越好。

抽包会带来新的维护成本:

  • 包名要维护
  • 入口要维护
  • 构建产物要维护
  • 版本或依赖关系要维护
  • 改共享包会影响多个应用

更稳的判断是:

重复出现 2 次,可以先观察。
重复出现 3 次,而且改起来烦,再抽。

错误 7:一次性重构整个仓库

这是最容易翻车的。

更稳的顺序是:

先 workspace
再统一 scripts
再抽共享包
再接 turbo
最后优化 CI

每一步都能单独验证,出问题也容易回退。

11. 命令速查表

命令作用
pnpm install安装整个 workspace 的依赖
pnpm -r build对所有 workspace package 执行 build
pnpm --filter website dev只启动 website
pnpm --filter zero-ai build只构建 zero-ai
pnpm add lodash --filter website给 website 安装 lodash
pnpm add @zero-one/utils --filter website给 website 添加本地共享包
pnpm add -D turbo -w在根目录安装 Turborepo
pnpm turbo build用 Turborepo 构建所有相关项目
pnpm turbo build --filter=website只构建 website
pnpm turbo build --filter=website...构建 website 以及它依赖的包

12. 推荐学习顺序

不要一上来就背配置。

按这个顺序学会更稳:

  1. 先理解为什么多个项目会需要放一起。
  2. 再理解“放一个文件夹”和“monorepo 管理”有什么区别。
  3. 再学 pnpm-workspace.yaml 怎么声明 workspace。
  4. 再学 workspace:* 怎么引用本地包。
  5. 再学 --filter 怎么只操作某个项目。
  6. 最后学 Turborepo 的任务依赖、缓存和过滤执行。

如果你是在维护已有项目,推荐实践顺序是:

整理目录
统一项目名
统一 scripts
增加 pnpm-workspace.yaml
确认每个项目还能单独启动
抽第一个 packages/utils
再接入 turbo.json

13. 最后总结

概念简单意思你要记住
monorepo多项目放一个仓库它是组织方式,不是工具
pnpm workspace管多个包和本地依赖它解决包之间怎么引用
Turborepo管任务和缓存它解决怎么更快、更有顺序地跑命令
workspace:*使用本地 workspace 包不去远程 npm 找
--filter只操作指定项目日常开发很常用
turbo.jsonTurborepo 配置文件定义任务关系和缓存产物

最重要的一句话:

先判断多个项目有没有共享和统一管理的需要。
有需要,再用 monorepo 把项目组织起来。
然后用 pnpm workspace 把依赖关系管清楚。
等任务变多、变慢,再用 Turborepo 把任务跑聪明。

不要反过来。

工具是为项目服务的,不是项目为了工具服务。