Chapter 06

只跑该跑的

改一个包,不想把整个 monorepo 的 build 都触发一遍——--filter 精确圈定目标。部署单个应用时,turbo prune 从 100 个包里只拎出那一个需要的子图——让 Docker 镜像只装真正需要的依赖。

--filter 语法

pnpm turbo run build --filter=@myorg/web
# 只 build web 这一个包

pnpm turbo run build --filter=@myorg/web --filter=@myorg/api
# 两个包

pnpm turbo run build --filter="@myorg/*"
# scope 通配

依赖图过滤

pnpm turbo run build --filter="@myorg/web..."
# web 和它所有的依赖(含 ui/utils)

pnpm turbo run build --filter="...@myorg/utils"
# utils 和所有依赖 utils 的包(ui/web/docs)

pnpm turbo run build --filter="...@myorg/utils..."
# utils 的整个连通子图(上下游都要)
pkg...
pkg 和它的依赖(下游)——"想 build 这个,先把它依赖的 build 好"。
...pkg
pkg 和依赖它的(上游)——"改了这个包,哪些受影响"。
...pkg...
两边都要,整个传染链。

changed-since 过滤

pnpm turbo run build --filter="[origin/main]"
# 相对于 origin/main 分支,动过代码的包

pnpm turbo run build --filter="...[origin/main]"
# 改过的包 + 依赖这些包的包(传染)

pnpm turbo run build --filter="[HEAD^1]"
# 相对于上一个 commit

PR CI 最常用的组合——只跑和本次 PR 相关的任务:

# .github/workflows/ci.yml
- name: Build affected
  run: pnpm turbo run build test lint --filter="...[origin/main]"
PR CI 时间暴降的核心
100 个包的 monorepo,一个 PR 往往只改 1-2 个——--filter=...[origin/main] 让 CI 只跑相关的几个,配合远程缓存,PR CI 从 10 分钟降到 30 秒。

路径过滤

pnpm turbo run build --filter="./apps/*"
# 按路径 glob,不是包名

pnpm turbo run build --filter="./packages/ui-*"
# packages 下 ui- 前缀的子包

排除

pnpm turbo run build --filter="@myorg/*" --filter="!@myorg/docs"
# 所有 myorg 包,除了 docs

复杂组合

pnpm turbo run test \
  --filter="@myorg/web" \
  --filter="@myorg/api..." \
  --filter="!@myorg/legacy"
# web 自己 + api 和它的所有依赖 + 排除 legacy

和 pnpm --filter 的区别

pnpm -Fturbo --filter
目的选包跑 npm script选任务图的子集
知道任务图否(只看包依赖)是(含 dependsOn)
缓存
典型用法pnpm -F web devturbo run test --filter=...

两者可以共存——pnpm -F 用来手工启个 dev,turbo --filter 用来跑任务管线。

turbo prune 做什么

场景:Docker 部署 web app。你有 50 个子包,但 web 只用到其中 5 个。

传统做法:
  COPY . .                  ← 拷贝整个 monorepo(500MB)
  RUN pnpm install           ← 装所有包的依赖(20 分钟)

turbo prune 做法:
  pnpm turbo prune @myorg/web --docker
  → 生成 out/json(只有必要的 package.json 和 lockfile)
  → 生成 out/full(精简的源码)
  → Dockerfile 分层拷贝,缓存 pnpm install

prune 命令

pnpm turbo prune @myorg/web --docker
# 输出到 out/ 目录
out/
├── json/                       ← 只有 package.json 结构(含 lockfile)
│   ├── package.json
│   ├── pnpm-lock.yaml
│   ├── pnpm-workspace.yaml
│   ├── apps/web/package.json
│   ├── packages/ui/package.json
│   └── packages/utils/package.json
└── full/                       ← 完整源码(只含需要的包)
    ├── package.json
    ├── pnpm-lock.yaml
    ├── turbo.json
    ├── apps/web/
    │   ├── src/
    │   └── package.json
    └── packages/
        ├── ui/
        └── utils/

apps/docs、packages/legacy 等和 web 无关的都被裁掉——web 根本不需要它们。

Docker 多阶段构建

# Dockerfile
FROM node:20-alpine AS base
RUN corepack enable

# 1. 裁剪 monorepo
FROM base AS pruner
WORKDIR /app
COPY . .
RUN pnpm turbo prune @myorg/web --docker

# 2. 装依赖(只装 web 需要的)
FROM base AS installer
WORKDIR /app
COPY --from=pruner /app/out/json/ ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

# 3. 构建
COPY --from=pruner /app/out/full/ ./
RUN pnpm turbo run build --filter=@myorg/web

# 4. 产物镜像
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=installer /app/apps/web/.next/standalone ./
COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static
CMD ["node", "apps/web/server.js"]

为什么要分 json 和 full

镜像 layer 缓存原理:
  - 先 COPY package.json → RUN pnpm install → 缓存
  - 再 COPY 源码 → RUN pnpm build
  - 下次只改源码,package.json 没变 → install 层命中缓存

out/json 只含 package.json/lockfile,out/full 才是源码——分两步拷贝,让 install 层可以缓存。

prune 的效果对比

指标无 pruneturbo prune
Docker context500MB20MB
pnpm install 依赖数1200280
install 时间90秒25秒
镜像大小800MB180MB
docker build(改源码)3 分钟35 秒

prune 不是 dev 用的

不要在开发机跑 prune
prune 是一次性的部署裁剪——产物放在 out/,不改原仓库。本地开发还是用完整 monorepo。把 out/ 加到 .gitignore。

prune 和 Next.js standalone

// next.config.js
module.exports = {
  output: 'standalone',
  experimental: {
    outputFileTracingRoot: path.join(__dirname, '../../'),
  },
}

Next standalone 模式把必要的 node_modules 复制进 .next/standalone——配合 prune,最终镜像可以不用再装 node_modules,只要 COPY standalone 就行。

filter 实战组合

# 开发期:只跑前端和 API 的 dev
pnpm turbo run dev --filter="@myorg/web" --filter="@myorg/api"

# CI PR:只 build/test 受影响的包
pnpm turbo run build test lint --filter="...[origin/main]"

# 部署前:只构建要发的包
pnpm turbo run build --filter="@myorg/web^..."

# 发版:只跑 changesets 识别的
pnpm changeset version
pnpm turbo run build --filter="[main~1]"

filter 的陷阱

--filter + globalDependencies 变了
globalDeps(如根 .env、tsconfig.base)变化 → 所有任务 hash 变 → 即使 filter 只圈了一个包,也会重跑。因为 hash 的构成里有全局依赖。
PR 分支没 fetch main
[origin/main] 要求本地有 main 分支,CI 里 actions/checkout 默认只拉一个 commit——必须 fetch-depth: 0
filter 过度收窄
只写了 @myorg/web,没写 @myorg/web... → 依赖包的 build 没跑 → web 用到的 ui 还是旧产物。一般 ...@myorg/web 更安全。

verbose 模式调试

pnpm turbo run build --filter="...[origin/main]" --dry-run
# 看看到底哪些包被选中了
Packages in scope: @myorg/ui, @myorg/web
Tasks to Run:
  @myorg/ui#build, @myorg/web#build

如果选出来的包数不对,用 git log 看看 main 的 HEAD 是哪——可能只是本地过时了。

Monorepo Explorer

pnpm turbo ls
# 列出所有 workspace 包

pnpm turbo ls --filter="...@myorg/utils"
# 所有依赖 utils 的包

pnpm turbo ls @myorg/web --dependencies
# web 的依赖树

Turbo 2.x 新命令——不跑任务,只做 workspace 查询。排查"这个包被谁用了"时很方便。

本章小结