前端 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-linker、shamefully-hoist 这类选项收紧,还是会漏出来。查过 pnpm 的文档之后,我把根目录的 .npmrc 加了一行:
1shamefully-hoist=false 2strict-peer-dependencies=false
shamefully-hoist 默认就是 false,显式写出来只是提醒自己别手滑改成 true——改成 true 基本等于放弃 pnpm 的隔离能力,退化成 npm 式提升。这行配置排查完之后,那次幽灵依赖的根子其实是 packages/ui 的 package.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/ui 的 devDependencies 里的 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}
共享请求包一旦被多个应用依赖,破坏性改动影响会很大,统一错误结构、明确版本、补测试都很必要。共享包越底层,越要保守,比如 request、auth、config、ui theme 这些包,一次改动可能影响所有应用,它们的 API 要少而稳定,破坏性改动最好通过版本和迁移说明来做,不要在业务迭代里顺手改掉。
归属想清楚再谈效率
回到开头那次幽灵依赖:根子问题解决之后,我们把 shamefully-hoist=false 和 peer dependency 声明补齐,pnpm --filter ... why 也加进了 CI 的一步前置检查,避免同类问题再混进来。
现在评估一个团队要不要上 Monorepo,我会先问它有没有"必须一起改、一起验证"的真实需求。有,pnpm workspace 会很舒服:本地联调少很多发包和切仓库的折腾,组件库、请求包、主题包和业务应用可以在同一个 PR 里完成验证。没有,只是把几个项目堆进一个仓库,那它只会把安装、权限、CI 和发布都变复杂。
Monorepo 的工具本身不难,难的是想清楚哪些代码该成为包,哪些依赖该声明在哪一层,哪些脚本要全量跑,哪些包需要独立发布,哪些包的 API 稳定到能让别人放心依赖。先把包职责想清楚,再让 pnpm workspace 提高协作效率,顺序不能反。