前端 Monorepo 与 pnpm workspace:多包项目先把归属想清楚

上周把组件库从独立仓库并进业务仓库,用 pnpm workspace 重新组织之后,第一次在应用里 import 一个没有在 package.json 里声明过的包,居然编译通过了。这就是幽灵依赖:某个包被间接安装到了 node_modules 里,代码却能直接引用它。这个现象在 npm/yarn 的老项目里我见过不止一次,但这次比较意外的是——按理说 pnpm 应该比 npm/yarn 更不容易出这个问题。

原因得从两边的安装策略说起。npm 和 yarn 默认用"提升"(hoist)策略:不管依赖层级多深,尽量把包拍平放到根 node_modules 下面,这样磁盘占用小、路径浅,但代价是任何一个间接依赖都可能被提升到能被直接 require 到的位置,你的代码用了却没在 package.json 里声明,等哪天上游改了依赖树、这个包被提升的位置变了,线上直接炸。pnpm 反过来,用符号链接加一个扁平的 .pnpm 虚拟存储:所有包的真实内容只存一份、按内容寻址放在 node_modules/.pnpm 下面,项目里能访问到的只有 package.json 显式声明过的依赖,其余的包在符号链接层面根本不可见。这是 pnpm 一直被称为"天然杜绝幽灵依赖"的原因。

但 workspace 场景下这条防线会被自己人绕开——包之间的引用走的是 workspace:* 协议,而这层链接是按包级别建的,不是按依赖声明级别建的。如果 packages/ui 依赖了某个工具库,apps/admin 又直接依赖 packages/ui,那么在某些提升配置下,apps/admin 仍然可能摸到 ui 的间接依赖——不是因为 npm 式的全局提升,而是因为 monorepo 内多个包共享同一个 .pnpm 存储目录,符号链接的可见范围如果没有被 node-linkershamefully-hoist 这类选项收紧,还是会漏出来。查过 pnpm 的文档之后,我把根目录的 .npmrc 加了一行:

1shamefully-hoist=false
2strict-peer-dependencies=false

shamefully-hoist 默认就是 false,显式写出来只是提醒自己别手滑改成 true——改成 true 基本等于放弃 pnpm 的隔离能力,退化成 npm 式提升。这行配置排查完之后,那次幽灵依赖的根子其实是 packages/uipackage.json 漏声明了一个 peerDependencies,本该报错的地方因为某个历史遗留的 .npmrc 配置被压掉了。

Monorepo 要担的代价是什么

组件库和几个后台应用同时迭代的时候,Monorepo 的价值最直接:一个按钮组件改了状态样式,三个应用都要验证;请求包改了错误结构,业务应用和移动端 H5 都要跟着调。分散在多个仓库里,光是本地联调前先发一版临时包、每个仓库都 npm link 一下,就能耗掉半天。

但代价也是实打实的。几个几乎没关系的项目硬塞进一个仓库,装依赖变慢、CI 跑得久、权限模型变复杂——原来能按仓库设置的分支保护和审批规则,现在全都要在一个仓库里用路径规则模拟。我们内部工具链评估要不要迁移到 Monorepo 时,团队争论最大的不是"pnpm workspace 好不好用",而是"值不值得把发布节奏完全不同的几个项目绑在一起"。这一年 pnpm workspace 明显比去年热闹,认真评估甚至小范围试点的团队变多了,但它还谈不上是行业公认的标准答案——不少团队仍然在用 lerna 配 yarn workspace,迁移到 pnpm 更多是冲着安装速度和磁盘占用去的,不是因为原方案不能用。

一个实用的判断标准:如果一个改动经常需要同时改多个包、并且希望一次 PR 里完成验证,Monorepo 会有明显收益;如果只是想让仓库"看起来统一",收益就很可疑。适合的场景包括多个应用共享组件库、UI 组件和业务组件需要一起演进、多个包需要统一 lint/test/build、本地调试跨包依赖很频繁;不适合的场景是项目间几乎没有共享、团队权限边界非常独立、发布节奏完全不同且互不影响、仓库已经很大但缺乏治理能力。Monorepo 提升的是协作效率的上限,也提升了治理要求的下限,两者是绑在一起涨的。

基础目录结构与 .pnpm 存储

一个常见结构:

1my-workspace/
2  apps/
3    admin/
4    mobile/
5  packages/
6    ui/
7    utils/
8    eslint-config/
9  package.json
10  pnpm-workspace.yaml

pnpm-workspace.yaml

1packages:
2  - "apps/*"
3  - "packages/*"

package.json

1{
2  "name": "my-workspace",
3  "private": true,
4  "scripts": {
5    "build": "pnpm -r build",
6    "test": "pnpm -r test",
7    "lint": "pnpm -r lint"
8  }
9}

private: true 很重要,避免误把根项目发布出去。

值得单独说一下 node_modules/.pnpm 这层结构,因为它是理解上面幽灵依赖问题的关键。打开这个目录会看到一堆形如 [email protected] 的文件夹,每个真实包只存一份物理内容,其余地方全是指向它的符号链接(Windows 上是 junction)。项目根目录和每个子包的 node_modules 里看到的包,实际都是链接到 .pnpm 里的同一份内容,这也是 pnpm 磁盘占用比 npm/yarn 低得多的原因——同一台机器上不同项目用到同一个版本的包,物理上只占一份磁盘空间。缺点是如果某个工具依赖"深层扁平的 node_modules 结构"这种非标准假设(一些老旧的 webpack loader 或者手写脚本会直接拼路径找依赖),偶尔会因为路径变成了符号链接而找不到文件,这种坑目前只在个别老工具链上遇到过,出现频率不算高。

包之间如何引用

apps/admin 依赖 packages/ui

1{
2  "dependencies": {
3    "@acme/ui": "workspace:*"
4  }
5}

packages/ui/package.json

1{
2  "name": "@acme/ui",
3  "version": "0.1.0",
4  "main": "dist/index.js",
5  "types": "dist/index.d.ts",
6  "scripts": {
7    "build": "tsup src/index.ts --dts"
8  }
9}

workspace:* 表示引用当前 workspace 内的包,本地开发时修改 @acme/ui 能被应用直接感知,不需要先发布到 npm。这个协议实际有三种写法,语义不一样,第一次接触容易混:

1{
2  "dependencies": {
3    "@acme/ui": "workspace:*",
4    "@acme/request": "workspace:^",
5    "@acme/utils": "workspace:~1.2.0"
6  }
7}

workspace:* 在发布时会被替换成 ui 当前的真实版本号(精确匹配);workspace:^workspace:~ 发布时会替换成对应的 ^/~ 范围。也就是说这个协议只在本地开发阶段生效,一旦执行 pnpm publish,pnpm 会自动把 workspace: 前缀转换成真实的版本号或版本范围写进发出去的包里——这是刻意设计的行为,避免消费方装到一个指向"workspace"这种不存在语义的版本号。

不要把 workspace 包写成普通版本号,比如 "@acme/ui": "^0.1.0"。这样写虽然大概率也能命中本地包(因为 pnpm 会优先在 workspace 内找匹配的版本),但语义不明确,遇到版本号对不上(比如本地包还没来得及升版本号)就会真的去 registry 拉取,行为变得难以预测。内部包之间我现在统一用 workspace:*,让依赖关系明确指向仓库内包,不留歧义。

排查当前到底链接到了哪里,可以用:

1pnpm --filter admin why @acme/ui

如果输出里看到的是 workspace 路径,说明本地包链接正常;如果看到 registry 版本,就要检查依赖声明或包名是否对得上。多包项目里"我改了 ui 但应用没变化",很多时候就是依赖没指到 workspace 包。

依赖应该装在哪里

Monorepo 里依赖管理最容易混乱,我的规则是:

  • 某个包运行时需要的依赖,装在该包 dependencies
  • 某个包构建或测试需要的依赖,装在该包 devDependencies
  • 全仓库统一工具(eslint、prettier、typescript)可以装在根目录
  • 不要让包依赖根目录里偶然存在的运行时依赖

packages/ui 如果是组件库、使用 vue,应该明确声明 peer dependency:

1{
2  "peerDependencies": {
3    "vue": "^3.2.0"
4  },
5  "devDependencies": {
6    "vue": "^3.2.0"
7  }
8}

这样组件库不会偷偷打包自己的 Vue,使用方也能明确知道需要提供 Vue。Vue 3 从今年 2 月起已经是 npm create vue 的默认版本,新拆出来的组件库基本都直接面向 Vue 3 设计;如果是存量 Vue 2 项目要复用,7 月发布的 Vue 2.7 把 Composition API、<script setup> 反向移植了回去,跨版本共享组件的写法可以往这个方向对齐,不必强行维护两套组件实现。

peer dependency 没声明清楚,最容易在 workspace 里暴露一个新问题:packages/uidevDependencies 里的 vue 版本,跟 apps/admin 实际用的 vue 版本不一致时,pnpm 默认的隔离策略会让两边各自解析到自己声明的版本,构建组件库时用的是一份 Vue,应用运行时又是另一份,排查起来比 npm 提升模式下的"版本冲突警告"更隐蔽——因为压根不会报错,只会在运行时出现莫名其妙的响应式失效或者组件不认识对方的 context。

脚本运行范围

pnpm 可以按包过滤执行:

1pnpm --filter @acme/ui build

也可以执行某个应用及其依赖:

1pnpm --filter admin... build

这对 CI 很有用,不是每次改一个工具函数都要全仓库构建。不过很多团队刚用 Monorepo 时先全量跑也没问题,等项目变大再做 affected 构建和缓存优化,过早引入复杂任务编排工具,可能会让团队先被工具链拖住。

--filter 的几种写法值得记清楚,CI 里很常用:

1# 构建 @acme/ui 以及它依赖的包
2pnpm --filter @acme/ui... build
3
4# 构建依赖 @acme/ui 的包,适合 ui 改动后验证影响面
5pnpm --filter ...@acme/ui build
6
7# 只跑某个应用自己的脚本
8pnpm --filter admin lint

这些命令我写进了项目文档,别让每个人都临时猜。Monorepo 的效率来自"能准确跑相关包",如果大家最后还是只会 pnpm -r build 全量跑,包一多反馈就会变慢。

TypeScript 路径和构建产物

多包项目里,TypeScript 引用有两种思路。开发时直接引用源码,体验好:

1import { Button } from '@acme/ui'

但发布时应该引用构建产物和类型声明,组件库包要生成 dist.d.ts

1{
2  "main": "dist/index.js",
3  "module": "dist/index.mjs",
4  "types": "dist/index.d.ts"
5}

不要只在应用里通过 tsconfig paths 指向源码,然后忘了包本身无法独立构建,否则这个包离开当前仓库就不能使用。今年 11 月 TS 4.9 发布之后,satisfies 操作符对写这类共享包的类型声明挺有用——比如给包的配置对象既要做类型校验、又想保留字面量类型用于后续推断:

1type PackageExport = {
2  main: string
3  types: string
4}
5
6const exportsConfig = {
7  main: 'dist/index.js',
8  types: 'dist/index.d.ts',
9} satisfies PackageExport
10// exportsConfig.main 的类型是字面量 'dist/index.js',不是被拓宽成 string

以前只能用类型断言 as PackageExport,但断言会让 TypeScript 完全信任你的标注、放弃多余属性检查;satisfies 既校验了结构又不丢字面量类型,这种写法在 monorepo 里给每个包的元信息做集中管理时很实用。

我要求每个可复用包至少能独立跑三件事:

1pnpm --filter @acme/ui lint
2pnpm --filter @acme/ui test
3pnpm --filter @acme/ui build

如果一个包只能在某个应用里顺带跑通,它就还不是一个真正的包,只是被挪到 packages 目录里的业务代码。

版本发布要提前设计

Monorepo 发布通常有两种模式:固定版本(所有包用同一个版本号,简单,适合强绑定的一组包)和独立版本(每个包单独升级,灵活,适合工具包、组件库分别演进)。

小团队一开始不要把发布流程设计得过于复杂,可以先内部使用 workspace,不急着发布到 npm。等包的对外接口稳定后,Changesets 是目前评估下来体验最顺的版本管理工具——每次改动完加一条 changeset 记录(改了哪个包、算 major/minor/patch、写一句变更说明),发布时统一收集这些记录、批量升版本号并生成 changelog:

1pnpm changeset
2# 交互式选择本次改动涉及的包,以及各自的版本类型
3
4pnpm changeset version
5# 汇总所有未发布的 changeset,批量升版本号、写 changelog
6
7pnpm changeset publish

它比手工维护每个包的版本号靠谱的地方在于,能自动处理"ui 升了 major,依赖它的 request 是不是也要跟着升"这类连锁关系,不需要人工记住所有包之间的依赖图。我们目前还在小范围试用,仅限内部工具包这条线,业务应用暂时没有跟进,毕竟应用本身不对外发布,用不上这套版本号语义。

CI 不要一开始就全仓库重跑

Monorepo 早期全量跑 lint、test、build 没问题,简单可靠。但包多起来以后 CI 会越来越慢,这时候要考虑 affected 范围:改了 packages/ui,需要跑依赖它的应用;只改文档,不一定要全量构建。

pnpm 的 filter 能先解决一部分问题:

1pnpm --filter @acme/ui... build

不过 affected 构建不要太早复杂化,我的经验是先全量跑,等慢到确实影响团队,再引入缓存和任务编排。工程化优化最好有明确痛点,不要为了工具链本身而工具链——今年团队内部工具链评估过把部分构建脚本切到 Vite 3(7 月刚发布),速度提升很直观,但也只敢先在内部工具包上用,业务应用的构建链路还是 webpack 5,没有必要为了赶新鲜一次性全切。

API 错误和共享请求包

很多 Monorepo 会抽一个 packages/request,给多个应用复用请求逻辑,这是好事,但要把错误结构设计稳定:

1export type ApiError = {
2  status: number
3  code: string
4  message: string
5}
6
7export async function request<T>(url: string): Promise<T> {
8  const response = await fetch(url)
9  const body = await response.json().catch(() => null)
10
11  if (!response.ok) {
12    throw {
13      status: response.status,
14      code: body?.error?.code || 'REQUEST_FAILED',
15      message: body?.error?.message || '请求失败',
16    } satisfies ApiError
17  }
18
19  return body as T
20}

共享请求包一旦被多个应用依赖,破坏性改动影响会很大,统一错误结构、明确版本、补测试都很必要。共享包越底层,越要保守,比如 requestauthconfigui theme 这些包,一次改动可能影响所有应用,它们的 API 要少而稳定,破坏性改动最好通过版本和迁移说明来做,不要在业务迭代里顺手改掉。

归属想清楚再谈效率

回到开头那次幽灵依赖:根子问题解决之后,我们把 shamefully-hoist=false 和 peer dependency 声明补齐,pnpm --filter ... why 也加进了 CI 的一步前置检查,避免同类问题再混进来。

现在评估一个团队要不要上 Monorepo,我会先问它有没有"必须一起改、一起验证"的真实需求。有,pnpm workspace 会很舒服:本地联调少很多发包和切仓库的折腾,组件库、请求包、主题包和业务应用可以在同一个 PR 里完成验证。没有,只是把几个项目堆进一个仓库,那它只会把安装、权限、CI 和发布都变复杂。

Monorepo 的工具本身不难,难的是想清楚哪些代码该成为包,哪些依赖该声明在哪一层,哪些脚本要全量跑,哪些包需要独立发布,哪些包的 API 稳定到能让别人放心依赖。先把包职责想清楚,再让 pnpm workspace 提高协作效率,顺序不能反。