前端 monorepo 的依赖管理:pnpm workspace 与 changesets 的版本发布流程

深入讲解前端 monorepo 下如何使用 pnpm workspace 管理多包依赖,并结合 changesets 完成版本变更、版本升级与自动发布,帮你避开多包仓库中的常见坑。

当一个仓库里的包多起来以后,依赖管理就不再只是执行一遍 npm install 的事了。同一个包可能在多个组件里被引用,某个工具函数升级了,所有上游包都要重新构建;某个包的版本号改错了,线上就会指向一个不存在的版本。前端 monorepo 的依赖管理,本质上是在处理两件事:依赖的安装与链接,以及依赖的版本发布。pnpm workspace 解决前者,changesets 解决后者。

AI technology illustration

很多团队一开始把多个包塞进一个 git 仓库,只是为了方便统一提交代码。但真正让 monorepo 产生价值的,是包与包之间的本地联动。想象一下这样的场景:你维护一个组件库,包含按钮、表单、弹窗等十几个包,它们之间又有依赖关系。如果分开仓库,每次改动子包都要先发版,再在父包里等 npm 同步、更新版本号、重新安装。在 monorepo 里,通过 workspace 就能直接引用本地源码,改完立刻生效。pnpm 在这条路上的优势尤其明显。

pnpm workspace 怎么管理多包依赖

pnpm workspace 的配置入口是根目录的 pnpm-workspace.yaml,声明哪些目录是独立的包:

packages:
  - packages/*
  - components/*
  - tools/*
  - '!**/test/**'

这段配置告诉 pnpm,只要匹配到 packages 下的子目录,就把它当作一个 workspace 包。执行 pnpm install 时,pnpm 会为整个仓库生成一个统一的依赖结构。与其他包管理器最大的区别在于,pnpm 默认会把依赖提升到 node_modules/.pnpm,然后通过符号链接在包之间建立引用。

这样做的好处很直接:依赖不会散落在每个包里,同一个依赖版本在磁盘上只有一份物理文件,大量减少重复安装。相比之下,npm 和 yarn classic 的 hoist 策略虽然也能减少一些重复,但一旦出现依赖冲突,很多包会各自拿到自己的副本,最终造成磁盘浪费和安装缓慢。更重要的是,pnpm 的严格依赖隔离能天然规避“幽灵依赖”的问题——一个包能 require 什么,完全取决于它在 package.json 中声明了什么。

内部依赖要用 workspace: 协议

在多包仓库里,包与包之间的依赖会写入 dependenciesdevDependencies。如果直接写 "@my/ui": "^1.0.0",执行 pnpm install 时,pnpm 会尝试从 npm registry 拉取这个包,而不是链接到本地 workspace。正确的方式是使用 workspace: 协议:

{
  "name": "@my/components",
  "dependencies": {
    "@my/button": "workspace:*",
    "@my/theme": "workspace:^1.2.0"
  }
}

workspace:* 表示引用当前仓库里任意版本的本地包,而 workspace:^1.2.0 则表示只接受本地满足该 semver 范围的版本。这个设计非常聪明,因为它既保留了本地联动能力,又允许你为每个依赖定义严格的版本边界。

一个常见的误区是:发布时可以不带 workspace: 协议直接发布。pnpm 在执行 pnpm publish 时,会自动把 workspace: 转换成对应的真实版本号。如果你用了 workspace:*,转换后的版本号就是当前对应包的本地版本。所以本地开发和发布依赖的版本语义其实是分离的,这也是很多团队一开始会困惑的点。

构建顺序与增量构建

依赖关系确定后,构建顺序就成了新问题。一个组件包依赖了另一个组件包,修改底层包后,上层包需要重新构建和测试。如果你手动按依赖顺序逐个执行,也许在小仓库里还能忍受,但包一多就会出错。

pnpm 提供了 --filter--sort 来支持拓扑排序。例如,要针对所有依赖 @my/theme 的包执行构建,可以这样写:

pnpm --sort --filter @my/theme -r run build

-r 表示递归工作区中的所有包,--sort 会按照依赖关系从底层开始执行。这意味着你不再需要维护一份手动顺序的脚本清单。更推荐的做法是在根目录的 package.json 中定义统一的构建命令,通过 CI 阶段并行或串行调度。

但这里有一个容易踩的坑:如果你的包之间存在循环依赖,无论用 pnpm 还是 changesets,都无法自动解决。比如 @my/ui 依赖 @my/icons,而 @my/icons 又反向依赖 @my/ui 的工具函数。这种依赖图在 monorepo 中非常常见,但必须通过重构拆出共享模块来消除。否则即使本地能跑,发布时也会出现版本更新顺序混乱的问题。

changesets 如何管理版本发布

依赖安装和构建准备好了,接下来是版本发布的环节。手动管理多个包的版本号是一场灾难:你需要记住哪些包发生了 breaking change,哪些只是 patch,还要同步更新依赖关系中的版本范围。changesets 的做法是把这些决策提前到开发时。

changesets 的核心思路是:每个版本变更的 PR 都要附带一个 changeset 文件,里面记录这个变更影响的包、版本更新类型和描述。这个文件会被纳入 git 提交,作为后续版本升级的依据。

首次使用,先安装并初始化:

pnpm add -Dw @changesets/cli
pnpm changeset init

执行 pnpm changeset 时,它会交互式询问你选择哪些包、是 patch/minor/major,并让你填写变更说明。生成的文件看起来像这样:

---
'@my/button': minor
'@my/components': patch
---

按钮新增 loading 状态,组件库同步更新类型定义。

这个文件被提交后,在发布前执行 pnpm changeset version,changesets 会读取所有 changeset 文件,计算出每个包的新版本号,更新各自的 package.json,并生成或追加 CHANGELOG.md。最后再执行 pnpm changeset publish 把所有需要发布的包推送到 npm registry。

一次完整的发布流程

在实际项目中,发布流程通常是这样串联的:

  1. 开发完成后,在 PR 里执行 pnpm changeset 并提交 changeset 文件。
  2. 合并代码到主干,触发 CI 中的发布流水线。
  3. CI 执行 pnpm installpnpm -r run build,确保所有包构建通过。
  4. 执行 pnpm changeset version,让 changesets 更新版本号与 changelog。
  5. 提交版本更新产生的变更,通常由 CI 自动提交并推送回仓库。
  6. 执行 pnpm changeset publish 发布所有待发布包。
  7. 发布后,changesets 会创建 git tag 标记对应的 commit。

这里最容易被忽略的是第 5 步。如果你用的是个人启用的 npm token,CI 上的 publish 权限不会自动延伸到 git 操作。changesets 需要配置一个有写权限的 GitHub token 或 GitLab token,才能把版本更新提交推回仓库。很多团队以为 changesets 只负责发版,没想到还要处理 git 仓库的读写,第一次跑 CI 时就会卡在这一步。

pnpm + changesets 的版本集成

changesets 本身不关心你用的是 pnpm 还是 npm。关键在于发布时 workspace: 协议的处理。pnpm 官方在 .npmrc 中提供了配置,可以让 publish 时自动替换版本号:

// .npmrc
link-workspace-packages=false
workspace-packages-are-linked=true

实际上,pnpm 默认会在 publish 时转换 workspace: 关联到具体的 semver 范围。例如 workspace:* 会转换为目标包的实际版本号,workspace:^ 会转换为对应的 caret 范围。这一点是 pnpm 与 changesets 配合的关键,因为 changesets 不会修改 workspace 协议中声明的关联关系,但它会更新依赖包的版本号,pnpm 在发布时则会根据最新的版本号完成替换。

如果你的组件库有类似 @my/theme 这样的基础包升级,changesets 会自动把依赖它的其他包也标记为需要 bump。这取决于你在 changeset 里选择的版本类型。比如给 @my/theme 选择 minor 升级,那依赖它的包至少需要 patch 升级。changesets 会分析依赖图,并自动建议这些联合变更,但最终仍然需要人工确认。

对比:changesets、lerna 和手动发布

到底选哪种版本发布方案,不能只看名气,还得看团队的使用场景。下面这张表总结了三种主流方式的差异:

方案 版本管理方式 依赖联动 学习成本 适用场景
手动 npm publish 手动改 package.json 再发布 容易漏,需手动更新相关包 包少、变更不频繁的团队
Lerna 固定/独立版本,支持自动 bump 有,但依赖 pnpm/npm 配合 传统 monorepo,偏好集中控制
Changesets 独立版本,基于 changeset 文件 自动分析依赖图并建议变更 低到中 组件库、多包开源项目

Lerna 曾经是 monorepo 的代名词,但它的版本管理逻辑比较重,且多年来 API 一直有调整。如果你不是维护一个超大仓库,changesets 的独立版本模式更符合现代前端的粒度控制需求。而且 changesets 本身就是很多知名开源库(如 vite、svelte)在用的方案,生态维护足够活跃。

还有一个容易被忽略的点:changesets 默认是“每个包独立版本”,不会强制所有包一起升到同一个大版本。如果你需要类似 Lerna 的 fixed mode(所有包统一版本),changesets 目前并不原生支持。这种场景下你可能需要额外的脚本或直接选择 Lerna。

实际落地时的几个关键问题

原理都清楚了,最终还是要踩一遍真实的坑。我这里列几个高频问题,希望能帮你提前避开。

发布前必须保证所有包都是干净状态

如果你在本地执行 pnpm changeset publish,它不会检查 git 工作区是否干净。一旦发布中途失败,已经发布的包和未发布的包就会处于不一致状态。建议在 CI 里先执行 git status --porcelain 做脏检查,确保版本更新已经被提交。

changesets 对 prerelease 的处理

如果你在维护 alpha、beta 版本,changesets 需要配合 pre 模式使用。执行 pnpm changeset pre enter next 进入 prerelease 模式,之后产生的 changeset 都会指向 next 版本,changeset version 会生成 1.1.0-next.0 这类版本号。等统一发布日,再用 pnpm changeset pre exit 退出预发布模式。很多团队在测试开源组件库时会遇到这个问题,不进入 pre 模式就手动改了版本号,结果 changesets 的版本计算会对不上。

依赖范围不能一味使用 workspace:*

workspace:* 很方便,但也会掩盖依赖版本的问题。如果你的两个包之间存在紧密的 API 耦合,workspace:* 在发布时会被转换成精确版本,比如 1.2.3,这会让所有下游包在升级时都被强制跟着升级,造成不必要的连锁发布。反过来,如果用 workspace:^,发布后转成 ^1.2.3,下游包只有在兼容范围内才会自动安装上,更符合 semver 的预期。

发布顺序与包并发发布

changesets 发布时默认是并发的。对于依赖关系复杂的仓库,并发发布可能造成窗口期依赖不存在。虽然 npm 的 semver 范围会限制安装版本,但如果你使用精确版本,就可能在发布中途被下游包抓到不完整的版本。推荐在发布命令中限制并发数,或者依赖 pnpm 的 --filter 来串行发布底层包。

pnpm changeset publish --tag next --no-git-checks

这段命令适合在 CI 中使用,--no-git-checks 会跳过分支和干净状态检查,具体要不要加取决于你的流程管控。

把发布流程固化到 CI 中

聊到这里,依赖管理和版本发布的整体轮廓已经出来了。但很多团队还是会在 CI 里卡住,因为本地跑得好好的流程,一到自动化环境就出各种问题。下面这份偏实战的 checklist,是大多数前端 monorepo 项目可以照用的:

  • 根目录保留 pnpm-workspace.yaml,确保所有包都能被 pnpm 识别。
  • 内部依赖统一使用 workspace: 协议,避免本地安装版本漂移。
  • 在 CI 中先执行 pnpm install --frozen-lockfile,避免 lockfile 被意外更新。
  • 发布前单独跑一次 pnpm --sort -r run build,确保依赖构建顺序。
  • changesets 版本更新提交后,再跑 publish,保证 git tag 与仓库状态一致。
  • 准备好 CI 的 npm token 和 git token,分别配置在 package manager 和 changesets 的认证环境中。

这些步骤看似琐碎,但每一条背后都有对应的真实事故。比如 --frozen-lockfile 缺失,会导致 CI 重新解析依赖,出现本地和线上依赖不一致的情况。又比如没有提前验证构建,publish 后才发现编译产物缺失,只能马上补发一个 patch。

前端 monorepo 的依赖管理不存在银弹。pnpm workspace 提供了干净的依赖链接和高效的安装体验,changesets 则把版本发布的决策从发布时提前到开发时。两者配合,已经能覆盖绝大多数中大型前端仓库的需求。但关键还是你想清楚自己的包之间是什么耦合关系,需要多严格的版本边界,以及团队愿意花多少精力维护发布纪律。工具只是把规则固化下来,真正的规则还是由人定义的。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/770/

(0)
上一篇 1天前
下一篇 19小时前

相关推荐