首页
/ airi 的 pnpm Workspaces 实战:monorepo 工作区配置、workspace 协议与依赖过滤

airi 的 pnpm Workspaces 实战:monorepo 工作区配置、workspace 协议与依赖过滤

2026-09-05 20:52:53作者:温玫谨Lighthearted

本篇指南以 airi 仓库内的 pnpm Workspaces 参考文档为骨架,系统讲解 pnpm 对 monorepo(多包仓库)的原生支持:如何在仓库根目录定义工作区、如何用 workspace: 协议引用内部包、如何用 --filter 精确控制命令执行范围,以及工作区级配置项(Workspace Settings)与发布时的协议转换规则。结合 airi 这一包含 40 余个内部包的 Vue/TypeScript monorepo 的实际配置文件,你将掌握一套可直接复制到大型前端 monorepo 的 pnpm 工作区管理与构建编排方案。

工作区的定义:pnpm-workspace.yaml

pnpm 通过工作区(workspaces)内置支持 monorepo。启用方式是在仓库根目录创建 pnpm-workspace.yaml,用 glob 模式声明哪些目录是工作区成员。一个典型的最小配置如下(来自参考文档的通用示例):

packages:
  # Include all packages in packages/ directory
  - 'packages/*'
  # Include all apps
  - 'apps/*'
  # Include nested packages
  - 'tools/*/packages/*'
  # Exclude test directories
  - '!**/test/**'

其中 glob 支持 *** 等通配符,以 ! 开头表示排除。packages/** 这类写法会递归匹配目录下所有含 package.json 的项目。

airi 的实际工作区定义

airi 根目录的 pnpm-workspace.yaml 展示了一个真实大型 monorepo 的工作区声明:

shellEmulator: true
packages:
  - packages/**
  - plugins/**
  - integrations/**
  - services/**
  - examples/**
  - docs/**
  - engines/**
  - apps/**
  - server/**
  - '!**/dist/**'

几个值得注意的点:

  • 九个顶层目录(packages/apps/plugins/integrations/services/docs/engines/server/ 等)全部按 ** 递归纳入,最终形成 40 余个内部包;
  • 最后一行 '!**/dist/**' 是排除规则,防止把构建产物目录误认为工作区成员——这是文档示例中 '!**/test/**' 排除语法在 airi 中的对应实践;
  • 同一份 package.json(根包,名为 @proj-airi/root"private": true)里还保留了一个 "workspaces" 字段,声明了与 pnpm-workspace.yaml 完全一致的九个 glob。pnpm-workspace.yaml 是 pnpm 的权威来源,package.json 中的 workspaces 字段则服务于 npm/yarn 生态的工具识别,两者保持同构可以让不同工具链对"哪些目录是工作区"达成一致。

工作区声明只是第一步。airi 把这一层做得更完整:同一个 pnpm-workspace.yaml 中还集中了 catalog/catalogs(版本目录)、overrides(依赖覆盖)、patchedDependencies(补丁依赖)、allowBuilds(允许的 postinstall 构建)等配置,全部集中在一处管理,避免了多个配置文件散落的问题。这与下文最佳实践第 6 条"把所有 pnpm 设置放进 pnpm-workspace.yaml"的思路一致。

workspace: 协议:引用本地包

工作区内引用内部包不应写死版本号(一改版本就要逐包同步),而应使用 workspace: 协议,让 pnpm 自动解析并链接到本地路径:

{
  "dependencies": {
    "@myorg/utils": "workspace:*",
    "@myorg/core": "workspace:^",
    "@myorg/types": "workspace:~"
  }
}

协议变体与发布行为

协议 行为 发布时转换为
workspace:* 任意版本 实际版本(如 1.2.3
workspace:^ 兼容版本 ^1.2.3
workspace:~ 补丁版本 ~1.2.3
workspace:^1.0.0 明确的 semver 范围 ^1.0.0

airi 中协议的实际用法

airi 各应用普遍采用 workspace:^ 来引用共享库。例如 apps/stage-pocket/package.json 中声明了十几个内部依赖:

{
  "dependencies": {
    "@proj-airi/audio": "workspace:^",
    "@proj-airi/ccc": "workspace:^",
    "@proj-airi/i18n": "workspace:^",
    "@proj-airi/pipelines-audio": "workspace:^",
    "@proj-airi/server-sdk": "workspace:^",
    "@proj-airi/stage-ui": "workspace:^",
    "@proj-airi/stage-ui-live2d": "workspace:^",
    "@proj-airi/stage-ui-three": "workspace:^",
    "@proj-airi/stream-kit": "workspace:^",
    "@proj-airi/ui": "workspace:^"
  }
}

apps/stage-tamagotchi/package.json(Electron 桌面应用)则同时出现了两种变体:"@proj-airi/server-sdk": "workspace:*" 与其余包的 "workspace:^"。这恰好对应协议变体表中的两行:workspace:* 发布时落成具体版本号,workspace:^ 发布时保留 caret 范围。

在 lockfile 层面,这些协议依赖被完整记录。pnpm-lock.yamllockfileVersion: '9.0')的 importers 段中可以看到每个包名与其协议 specifier 的映射:

'@proj-airi/audio':
  specifier: workspace:^

这保证了任何一台机器执行 pnpm install 时,内部依赖一律链接到本地工作区包,而不是从 registry 拉取同名包——这是"发布前始终是本地源码、发布后才变成 registry 版本"这一机制的落盘形态。

过滤包:--filter 与递归执行

大型工作区不可能对每次命令都遍历所有包,--filter(简写 -F)用于把命令限制在指定包上。

基础过滤方式

# By package name
pnpm --filter @myorg/app build
pnpm -F @myorg/app build

# By directory path
pnpm --filter "./packages/core" test

# Glob patterns
pnpm --filter "@myorg/*" lint
pnpm --filter "!@myorg/internal-*" publish

# All packages
pnpm -r build
pnpm --recursive build

按名称、按目录路径(./ 前缀)、按 glob、按取反排除(! 前缀)四种方式可以覆盖绝大多数场景;-r / --recursive 则等价于"全部工作区包"。

基于依赖关系的过滤

# Package and all its dependencies
pnpm --filter "...@myorg/app" build

# Package and all its dependents
pnpm --filter "@myorg/core..." test

# Both directions
pnpm --filter "...@myorg/shared..." build

# Changed since git ref
pnpm --filter "...[origin/main]" test
pnpm --filter "[HEAD~5]" lint

前后缀 ... 表示沿依赖图向"上游"(依赖方向)或"下游"(被依赖方向)递归;[git-ref] 形式则用于增量构建——只挑选自某个 git 引用以来发生过变更的包(及其依赖闭包)。

airi 根脚本中的过滤用法

airi 根 package.jsonscripts 是过滤语法的一组真实用例:

"dev": "pnpm -r -F @proj-airi/stage-web dev",
"dev:apps": "pnpm -rF=\"./apps/*\" run --parallel dev",
"dev:packages": "pnpm -rF=\"./packages/*\" --parallel run dev",
"typecheck": "pnpm -rF=\"./packages/*\" -F=\"./apps/*\" -F=\"./server/**\" -F=\"./docs\" --parallel typecheck",
"test-stage-tamagotchi:run": "vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser"

可以看到三类用法并存:

  • -F @proj-airi/stage-web 按包名过滤,配合 -r 递归执行对应脚本;
  • -F="./apps/*"-F="./packages/*" 按目录 glob 过滤,一次覆盖整个目录族;
  • -F 参数叠加(typecheck 一行串了四个 filter),并加 --parallel 让各包并行执行。

此外 airi 的顶层 build 并未直接用 pnpm -r build,而是交给 Turbo 做拓扑编排:

"build": "turbo run build -F=\"./packages/*\" -F=\"./apps/*\" -F=\"./server/**\"",
"build:packages": "turbo run build -F=\"./packages/*\""

配套的 turbo.json 通过 "dependsOn": ["^build"] 声明"先构建上游依赖包再构建自身",并对个别包(如 @proj-airi/electron-vueuse 依赖 @proj-airi/electron-eventa)做了显式依赖补强。这对应最佳实践第 4 条"构建时按依赖过滤以保证正确顺序"——pnpm 的 --filter 与 Turbo 的 dependsOn 在这里形成互补。

工作区命令:安装、脚本与 exec

安装依赖

# Install all workspace packages
pnpm install

# Add dependency to specific package
pnpm --filter @myorg/app add lodash

# Add workspace dependency
pnpm --filter @myorg/app add @myorg/utils

pnpm install 在仓库根目录执行一次即可安装全部工作区成员;--filter 定位到具体包后执行 add,pnpm 会判断目标是否为本工作区包——是则写入 workspace: 协议,否则走 registry。

运行脚本

# Run in all packages with that script
pnpm -r run build

# Run in topological order (dependencies first)
pnpm -r --workspace-concurrency=1 run build

# Run in parallel
pnpm -r --parallel run test

# Stream output
pnpm -r --stream run dev

四个关键开关:-r 限定"有该脚本的包"、--workspace-concurrency 控制并发度(设为 1 即严格串行,配合拓扑顺序保证依赖先行)、--parallel 忽略拓扑全并行、--stream 把各包输出实时混流到终端。

执行任意命令

# Run command in all packages
pnpm -r exec pwd

# Run in specific packages
pnpm --filter "./packages/**" exec rm -rf dist

exec 用于执行非 npm script 的任意 shell 命令,适合批量清理 dist/ 这类产物目录(与 airi 工作区声明中 '!**/dist/**' 的排除规则互为呼应:dist 是各包产物,既不属于工作区,也常被 exec 批量清理由此管理)。

工作区设置:pnpm-workspace.yaml 中的 camelCase 配置项

参考文档指出一个重要的演进点:工作区级设置从 .npmrc 迁出,统一写入 pnpm-workspace.yaml 并使用 camelCase 键名。完整示例如下:

packages:
  - 'packages/*'

# Link workspace packages automatically
linkWorkspacePackages: true
# Prefer workspace packages over registry
preferWorkspacePackages: true
# Single lockfile for the whole workspace (recommended)
sharedWorkspaceLockfile: true
# Workspace protocol handling on publish
saveWorkspaceProtocol: rolling
# Concurrent workspace scripts
workspaceConcurrency: 4
# Use root deps to resolve peers of all projects
resolvePeersFromWorkspaceRoot: true
# Scripts required in every project (else `pnpm -r run <name>` fails)
requiredScripts:
  - build

各项作用:

  • linkWorkspacePackages:依赖匹配到工作区内的包时自动改走本地链接,而不依赖显式 workspace: 前缀;
  • preferWorkspacePackages:版本范围命中时优先选工作区包而非 registry 版本;
  • sharedWorkspaceLockfile:整个工作区共用根目录一份 pnpm-lock.yaml(推荐,保证一致性);
  • saveWorkspaceProtocol:控制发布时协议转换策略(如 rolling 随版本滚动);
  • workspaceConcurrency-r 脚本默认并发数;
  • resolvePeersFromWorkspaceRoot:用根目录依赖统一解析各项目的 peerDependencies,减少 peer 冲突告警;
  • requiredScripts:声明每个项目必须具备的脚本,缺失时 pnpm -r run <name> 直接失败,可防止"部分包漏配脚本"造成的静默跳过。

airi 的 pnpm-workspace.yaml 印证了这条演进路径:仓库中不存在 .npmrc 文件,工作区配置全部集中在 pnpm-workspace.yaml,例如顶层的 shellEmulator: true,以及 pnpm-lock.yaml settings 段记录的 autoInstallPeers: trueexcludeLinksFromLockfile: false。单份根级 lockfile(lockfileVersion: '9.0')即"共享 lockfile"的体现。

另外,airi 的 pnpm-workspace.yaml 还启用了文档未展开的进阶能力,可作为"工作区单文件配置"思路的延伸:

  • catalog / catalogs:根 package.jsondevDependencies 大量使用 "catalog:""catalog:vitest" 引用(如 "typescript": "catalog:""vitest": "catalog:vitest"),版本号只在一处维护,对应最佳实践第 5 条"用 catalogs 统一管理共享外部依赖版本";
  • overrides:全工作区强制指定依赖版本或别名(如 axios: npm:feaxios@^0.0.23),一次生效于所有包;
  • patchedDependencies:把 patches/ 目录下的补丁文件挂到指定依赖上(如 mineflayer-pathfinderpixi-live2d-display);
  • allowBuilds:逐个声明哪些依赖允许执行 install 构建脚本(如 electron: truebetter-sqlite3: false),把构建权限收敛到显式白名单。

按包配置:packageConfigs

pnpm 不再提供子项目级 .npmrc,包粒度的差异配置从根文件的 packageConfigs 下发:

packageConfigs:
  project-1:
    saveExact: true
  project-2:
    savePrefix: '~'

例如让某包依赖一律写精确版本(saveExact: true),另一包保存时加 ~ 前缀。适合"大部分包统一策略、个别包特殊化"的场景。

发布工作区:协议转换与 CI 发布

pnpm publish 时会把 workspace: 协议转换为可发布的 semver 版本,外部用户看到的是常规版本范围:

// Before publish
{ "dependencies": { "@myorg/utils": "workspace:^" } }

// After publish
{ "dependencies": { "@myorg/utils": "^1.2.3" } }

CI 环境通常没有干净的 git 状态校验,发布时需要跳过:

pnpm publish -r --no-git-checks

结合前文协议变体表即可推断完整的发布链路:工作区内开发时依赖走本地链接,发布时 workspace:^^x.y.zworkspace:*x.y.z,消费方从 registry 安装时则与普通包无异。

最佳实践与项目结构

参考文档给出的六条最佳实践,逐条在 airi 仓库中都能找到对应物:

  1. 内部依赖一律使用 workspace: 协议 —— airi 各应用对 @proj-airi/* 依赖全部采用 workspace:^workspace:*(见 apps/stage-pocket/package.json);
  2. 启用 linkWorkspacePackages 自动链接 —— 配合 airi 的 overrides/catalog 体系,内部包解析始终收敛在工作区内;
  3. 使用共享 lockfile 保持一致 —— 全仓库仅根目录一份 pnpm-lock.yaml
  4. 构建时按依赖过滤保证顺序 —— 顶层 build 用 Turbo 的 dependsOn: ^build 拓扑排序(turbo.json),脚本层则大量使用 pnpm -rF 目录级过滤;
  5. 用 catalogs 统一共享外部依赖版本 —— catalog(400 余项)与具名 catalogs(如 vitest)定义在 pnpm-workspace.yaml,各包以 catalog: 引用;
  6. 所有 pnpm 设置集中在 pnpm-workspace.yaml(camelCase) —— 仓库无 .npmrc,工作区设置、overrides、补丁、构建白名单同处一文件。

文档最后给出的示例项目结构:

my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── pnpm-lock.yaml
├── packages/
│   ├── core/
│   │   └── package.json
│   ├── utils/
│   │   └── package.json
│   └── types/
│       └── package.json
└── apps/
    ├── web/
    │   └── package.json
    └── api/
        └── package.json

airi 的结构是该模板的超集:packages/apps/ 之外还纳入了 plugins/integrations/services/engines/server/ 等更多顶层目录,且每个成员包都遵循"目录 + package.json"的最小单元约定(如 packages/audioapps/stage-web)。

适用前提小结

  • 上述 pnpm-workspace.yaml camelCase 设置项(含 packageConfigscatalogsallowBuilds)依赖较新版本的 pnpm。airi 根 package.json 通过 "packageManager": "pnpm@11.24.0" 锁定了 pnpm 11 系列,lockfile 为 9.0 版本,阅读或复刻本文配置时请以同代 pnpm 为准;
  • --filter[git-ref] 增量过滤要求仓库处于可访问指定引用的 git 环境中;
  • 工作区配置与根 package.jsonworkspaces 字段宜保持同构,避免多包管理器工具对成员集合判断不一致;
  • 本文所有配置路径均相对 airi 仓库根目录,可直接打开核对:pnpm-workspace.yamlpackage.jsonpnpm-lock.yamlturbo.json
登录后查看全文
热门项目推荐
相关项目推荐