Chapter 06

只对一部分包动手

Monorepo 有几十个包,每次都全跑一次 build/test 不现实。--filter 让你精准选中「这个包」「这个包和它的依赖」「这个包和它的反向依赖」「改动影响到的包」,CI 才能快。

最基础:按名字选

pnpm --filter @myorg/web build
pnpm -F @myorg/web build         # 简写

# glob 模式
pnpm -F "@myorg/*" build       # 所有 @myorg 下的包
pnpm -F "./apps/*" build       # apps 目录下所有包(按路径)

依赖图过滤语法

语法含义例子
pkg只这个包-F @myorg/web
pkg...这个包 + 它依赖的所有(包含自己)-F "@myorg/web..."
...pkg这个包 + 所有依赖它的-F "...@myorg/ui"
pkg^...只它的依赖(不含自己)-F "@myorg/web^..."
...^pkg只依赖它的(不含自己)-F "...^@myorg/ui"
记忆口诀
... 在后面就是「往下看」(依赖树);在前面就是「往上看」(被谁依赖)。加 ^ 表示不包含自己。

直观例子

依赖关系:
  web → ui → utils
  docs → ui → utils

# 发 ui 改动 → 要先构建 utils 保证最新,再构建 ui
pnpm -F "@myorg/ui..." build
# 命中:utils, ui

# 发 ui 改动 → 下游 web/docs 也要重测
pnpm -F "...@myorg/ui" test
# 命中:ui, web, docs

# ui 改了,我想一次搞完上下游
pnpm -F "...@myorg/ui..." build
# 命中:utils, ui, web, docs(整条链)

按路径过滤

# 所有在 apps 下的包
pnpm -F "./apps/**" build

# 某个具体路径
pnpm -F "./packages/ui" test

glob 相对工作目录;名字和路径两种写法可以混用。

按变更过滤(CI 的灵魂)

# 相对 main 分支有改动的包
pnpm -F "...[origin/main]" build

# 改动 + 下游都测
pnpm -F "...[origin/main]" test

# 最近一次 commit 开始算
pnpm -F "...[HEAD~1]" build

[ref] 语法让 pnpm 用 git 比较:从 ref 到当前 HEAD 变动过的包——结合 ... 自动扩展到下游。

changed-since 是 CI 加速的秘诀
PR CI 只对改动影响的包跑 build/test,其余跳过。100 个包的 monorepo 改 1 个包,CI 从 10 分钟降到 30 秒。配合 Turborepo 缓存更省。

排除

# 除了 docs 都跑
pnpm -F "!@myorg/docs" build

# 组合:所有变更包,但不包含 docs
pnpm -F "...[origin/main]" -F "!@myorg/docs" build

并行和拓扑

pnpm -r build                       # 拓扑序(被依赖先跑)
pnpm -r --parallel dev               # 忽略拓扑,全部一起
pnpm -r --workspace-concurrency=4    # 最多 4 个并发
pnpm -r --no-bail test                # 遇到错误继续跑其它

几个常见 CI 模式

1. Build 只跑受影响的

# .github/workflows/ci.yml
- name: Checkout
  uses: actions/checkout@v4
  with:
    fetch-depth: 0              # 需要完整 git 历史

- run: pnpm install --frozen-lockfile
- run: pnpm -F "...[origin/main]" lint
- run: pnpm -F "...[origin/main]" test
- run: pnpm -F "...[origin/main]" build

2. 发布前只发改动的

# 只对修改过的 package 跑 changeset version / publish
pnpm changeset publish
# Changesets 自己识别谁变了、谁没变——很聪明

3. 启动开发环境

pnpm -F "@myorg/web..." --parallel dev
# 启动 web 和它依赖的所有包(watch mode)

--stream 看每个包的输出

pnpm -r --stream build
# 每行前面带包名前缀,多个输出交织但能区分

pnpm -r --reporter=append-only build
# CI 用,不显示进度条,日志更干净

exec:在 workspace 里跑任意命令

# 在 @myorg/web 目录下跑 ls
pnpm -F @myorg/web exec ls

# 所有 workspace 跑 eslint
pnpm -r exec eslint src

exec 不依赖 package.json 的 scripts——直接跑命令,常用在临时操作。

run 的区别

命令用途
pnpm -F pkg build跑 pkg 的 scripts.build
pnpm -F pkg run build同上,显式
pnpm -F pkg exec eslint .跑任意命令,不查 scripts
pnpm -F pkg add axios给 pkg 加依赖

filter 语法支持正则风格

pnpm -F "@myorg/{ui,utils}" test
pnpm -F "@myorg/*[test]" test        # 名字带 test 的
pnpm -F "!@myorg/*-config" build    # 排除 -config 结尾

cwd 作为隐式 filter

# cd 到 apps/web 目录
pnpm build
# 等价于 pnpm -F @myorg/web build(只跑当前包)

在子包目录里执行的命令默认只作用于当前包——符合直觉。

排错:我的 filter 不生效

# 先用 list 确认命中哪些
pnpm -F "...[origin/main]" ls --depth=-1

# 或 why 看依赖关系
pnpm why @myorg/ui
命中 0 个
检查 glob 是否需要引号(shell 会先展开);[ref] 要求 git 能 resolve,浅克隆会命中不到——CI 用 fetch-depth: 0
依赖没被带上
确认 package.json 的 dependencies 用 workspace:* 声明;写成 ^ 可能匹配到 npm 版本反而不算 workspace 内部依赖。
改了 README 也触发 build
ignored-changed-files-patterns 可忽略某些文件变化,或在 CI 用 changed-files-ignore-pattern=**/*.md

忽略特定文件的变化

# .npmrc
changed-files-ignore-pattern[]=**/*.md
changed-files-ignore-pattern[]=**/*.test.ts

CI 中,当只改了 README/测试文件时不触发下游重建。

真实 monorepo 的 CI 设计

jobs:
  lint:
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm -F "...[origin/main]" lint

  test:
    needs: lint
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm -F "...[origin/main]" test

  build:
    needs: test
    steps:
      - run: pnpm -F "...[origin/main]" build
      - run: pnpm -F "@myorg/web" deploy

三阶段 pipeline,每阶段只对改动影响到的包操作——100 包仓库改 1 个,5 分钟变 40 秒。

本章小结