前端 pnpm workspace 架构详解
一篇帮你搞懂 pnpm workspace 的实战向教程,从「为啥要用」到「怎么配」全给你捋清楚;每个知识点都会讲清是什么、为什么、怎么用、注意啥,方便你系统学习、随时查阅、直接落地。
一、先聊聊:我们到底遇到了啥问题?
做前端久了,多包、monorepo、组件库联调这些事一多,就会踩到一堆具体又磨人的坑。下面把这些痛点拆开说:具体表现 → 典型场景 → 对你有啥影响。搞清楚这些,后面再看 pnpm workspace 解决啥就一目了然。
1.1 node_modules 膨胀,磁盘和时间都遭殃
具体表现 :用 npm 搞 monorepo 时,根目录一个 node_modules,每个子包再来一个;或者多个独立项目各自一份。每个 node_modules 里,npm 会做扁平化 :把子依赖提升到顶层,同一份包可能在不同项目的 node_modules 里各存一份,重复拷贝。
典型场景 :比如你有一个 monorepo,里面 5 个 app、3 个共享库,都用 React、lodash、一堆 Babel/Webpack 相关包。单项目 node_modules 可能就 400~600MB,monorepo 里再乘上包数量、加上提升带来的重复,轻松破 2GB。npm install 第一次全量装要几分钟,以后每次 npm ci 或清缓存重装,体感也很慢。
影响 :占磁盘、拉代码慢、CI 缓存大、流水线耗时增加;本机多开几个项目,node_modules 动不动几十 GB。
1.2 依赖版本乱成一锅粥:幽灵依赖与冲突
幽灵依赖 的定义:某个包没有 在你自己的 package.json 的 dependencies / devDependencies 里声明,你却能在代码里 import 或 require 到它。常见原因就是 npm 的扁平化 :你装了 A,A 依赖 B,B 被提升到了项目根 node_modules,于是你的代码「意外」地能直接用 B。
典型场景 :你习惯性 import _ from 'lodash',但从没在 package.json 里加过 lodash,因为它是某个依赖的子依赖,被提升上来了。后来你升级了那个依赖,人家不再依赖 lodash,或者换了版本,你这边没改一行业务代码 就报错:找不到 lodash。更坑的是「本地能跑、CI 挂」:本地可能还有别的路径残留或缓存,CI 干净安装就炸。同理,删了某个你以为没用的依赖,结果别的地方一直隐式用着,一删就挂。
版本冲突:A 包要 React 18,B 包要 React 17,扁平化之后只能满足一边,另一边可能用了「不对」的版本,运行时才暴露问题,调试成本很高。
1.3 本地包联调贼麻烦:npm link 的坑
典型场景 :你维护一个业务组件库,要在另一个前端项目里联调。通常做法是 npm link:在组件库目录 npm link,在业务项目里 npm link your-components。但经常会遇到:
- 双实例问题:React、Vue 等对「单实例」有要求,link 过去可能出现两个版本,引发诡异 bug。
- bin 路径 :某些 CLI 或工具通过
node_modules/.bin找可执行文件,link 后路径解析不对,跑不起来。 - 不同 Node 版本 / 环境: link 的是「当时本机」的构建结果,换机器、换 Node、改点配置,行为可能不一致。
总之,改一下组件库就要反复 link、unlink、重装,体验很差,也容易忘步骤导致联调结果不可靠。
1.4 CI 又慢又占空间
典型场景 :每次 CI 全量 npm install,没有跨项目或跨 job 的 store 复用;缓存 key 设计不当(例如只按 package.json 不按 lockfile),导致缓存命中率低,每次都几乎全量装。加上前面说的 node_modules 巨大,流水线耗时长、占用空间大,体验和成本都不好。
上面这些,本质都可以归为两类问题 :一是多包怎么组织、怎么一起开发、怎么发布 (项目结构 + 工作流);二是依赖怎么存、怎么解析、怎么隔离 (存储与解析策略)。pnpm 的 workspace 就是在这两方面同时发力的方案之一:多包管理 + 更合理的依赖存储与解析。下面先把你可能最关心的------pnpm 底层是怎么干的------讲清楚,再回头看 workspace 具体解决了啥。
二、pnpm 底层原理:为啥能省空间、装得快、依赖还干净?
很多人只记住结论:「pnpm 省磁盘、快、没幽灵依赖」,但不知道它到底咋做到的。这一节把存储模型 和 node_modules 结构说透,你后面看配置、看优缺点都会更有数。
2.1 全局 store:content-addressable + 硬链接
pnpm 有一个全局 store ,所有安装过的包都会先放进这里,再通过硬链接 挂到各个项目的 node_modules 里。
-
存哪儿:
- Linux:默认
~/.local/share/pnpm/store - macOS:默认
~/Library/pnpm/store - Windows:默认
%LOCALAPPDATA%\pnpm\store(即C:\Users\<你>\AppData\Local\pnpm\store)
若设置了$XDG_DATA_HOME,Linux/macOS 会改用$XDG_DATA_HOME/pnpm/store。可通过.npmrc的store-dir覆盖,例如store-dir=D:\pnpm-store。
- Linux:默认
-
content-addressable(按内容寻址) :
包在 store 里按内容哈希存,同一版本、同一份包只存一份 。不同项目、不同 monorepo 子包,只要依赖的版本相同,都用这一份,去重、跨项目复用。
-
硬链接 :
硬链接可以理解为「同一份文件的多个路径入口」,改一处全体生效,但不额外占磁盘 。pnpm 从 store 把包硬链接到项目里的
node_modules/.pnpm/...,所以看起来每个项目都有一份,实际磁盘只存 store 里那一份。和复制 的区别:不占多余空间。和符号链接的区别:符号链接是「指向另一个路径」的小文件,硬链接是文件系统层面的多路径同一 inode,更省空间、也更稳定(删掉一个链接不会影响 store 里的那份,只要还有别的链接在)。
结果 :同 monorepo、同样依赖,用 pnpm 时磁盘占用往往只有 npm 的一半左右 (常见 benchmark 结论),二次安装时大量命中 store,pnpm install 明显更快。
2.2 node_modules 的真实结构:非扁平 + 严格依赖
npm 会把依赖扁平化 提升到顶层,所以你能「意外」用到子依赖;pnpm 不这么做 ,结构是非扁平的。
目录结构示意(精简版):
-
项目根目录的
node_modules/:- 只放你直接声明 的依赖(
dependencies/devDependencies里的包)。 - 这些「包名」多数是符号链接 ,指向
node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>。
- 只放你直接声明 的依赖(
-
node_modules/.pnpm/:- 里面才是实际内容(或链到 store)。
- 每个
package@version一个目录,且每个包有自己的node_modules,里面只装它自己的依赖。 - 子依赖不会提升到项目根
node_modules,所以你没法 在业务代码里require('某个未声明的子依赖')。
严格依赖 就是这样实现的:
只有 在 package.json 里显式声明的包,才会出现在你项目的 node_modules 顶层(或子包自己的 node_modules 里)。未声明的包根本不在你可访问的路径下,require / import 会直接报错,从根上杜绝幽灵依赖。
有些老旧工具会假设「所有依赖都在根 node_modules 扁平展开」,在 pnpm 默认结构下会找不到包。这时可以用 public-hoist-pattern 或 node-linker=hoisted 做有限提升,相当于在「兼容旧工具」和「严格依赖」之间做权衡;提升多了,幽灵依赖风险又回来了,所以能窄就窄。
2.3 workspace 包怎么被链接进来?
当你在 package.json 里写 "@my/ui": "workspace:*" 时,pnpm 会:
- 在
pnpm-workspace.yaml定义的目录里找到对应包(如packages/ui); - 把该包所在目录 (源码目录)链接到
node_modules里对应位置,不拷贝、不先打包。
所以,你改 packages/ui 的源码,消费方(例如 apps/web)立即可见 ,不用 npm link,也没有双实例、路径错乱那些破事。这就是 workspace 协议 带来的「本地包即源码」的联调体验。
2.4 和 npm / Yarn 的存储对比(简要)
- npm:扁平化 + 每项目各自拷贝,多项目多份;易幽灵依赖;安装速度、磁盘占用都一般。
- Yarn:经典模式类似 npm;Plug'n'Play 可选,但生态兼容性要看工具。
- pnpm :全局 store + 硬链接 + 非扁平
node_modules,省空间、安装快、默认严格依赖。
差异主要在存储与解析策略,而不是「有没有 workspace」这个概念。
三、pnpm workspace 解决了什么问题?(深化版)
有了第二节的原理打底,这里直接说 workspace 在「多包管理」场景下,具体帮你解决了啥;每个点都往「能用、能查」上靠。
3.1 磁盘与安装
- store + 硬链接:全 workspace 共享同一 store,同版本依赖只存一份;子包、apps 装依赖都是链过去,磁盘占用明显低于 npm 同规模 monorepo(约一半量级的说法很常见)。
- workspace 包不占 store :像
@my/utils、@my/ui这种本地包,pnpm 只做链接到源码目录,不往 store 里塞,也不拷贝,改完即生效。 - 安装速度 :
pnpm install在 monorepo 里通常比npm install快不少,尤其二次安装、CI 命中 store 时。
3.2 依赖隔离与一致性
-
幽灵依赖 :
pnpm 默认严格依赖 ,未声明就不能用。你刻意避免隐式依赖,配合 code review,能从根本上消灭「删了某依赖突然挂」「本地有 CI 没有」这类问题。
若必须兼容旧工具,再考虑
public-hoist-pattern有限提升,并清楚这会带来隐性依赖风险。 -
版本统一:
- 单一 lockfile :整个 workspace 只有一个
pnpm-lock.yaml在根目录,所有子包、所有环境的依赖解析都以它为准,版本全仓库一致,复现性高。 - catalog(pnpm 9+) :在
pnpm-workspace.yaml里定义catalog,给常用依赖约定版本(如react: ^18.3.1),子包用catalog:引用,升级时只改一处,避免各包各自为政。 - overrides :根
package.json里可配pnpm.overrides,强制某依赖在全 workspace 解析成指定版本,适合解决传递依赖冲突、安全修复等。
- 单一 lockfile :整个 workspace 只有一个
3.3 多包协作与发布
- 统一装依赖、统一跑脚本 :根目录一次
pnpm install,所有 workspace 包依赖都装好;用pnpm -r run build、pnpm --filter ...批量或定向跑脚本,配合根package.json的scripts,协作流程清晰。 - 按需发布 :
pnpm publish -r可递归发布,结合--filter只发布改动的包;配合 changesets 做 version + changelog + publish,适合多包独立发版。 - 权限与发包:可以按包名、按目录做 access 控制,和现有 npm registry 权限模型配合使用。
四、pnpm workspace 架构长什么样?
4.1 目录树与职责
下面是一个常见的 pnpm workspace 根目录结构,以及各部分的职责。
项目根目录
├── pnpm-workspace.yaml # 声明哪些目录是 workspace 包(唯一、仅根目录)
├── package.json # 根包:公共 devDependencies、批量脚本、overrides 等
├── pnpm-lock.yaml # 全 workspace 唯一 lockfile,所有人、CI 共用一个
├── .npmrc # 可选:store-dir、node-linker、hoist 等
├── packages/
│ ├── ui/ # 如:组件库
│ ├── utils/ # 公共工具
│ ├── config-eslint/ # 共享 ESLint 配置
│ └── ...
└── apps/
├── web/ # 前端应用
├── docs/ # 文档站
└── ...
-
根
package.json:- 放全仓库共用的 devDependencies(如 TypeScript、ESLint、Vitest、Prettier)。
- 定义
scripts,用pnpm -r、--filter批量或定向执行子包的 build、dev、test。 - 根包通常
"private": true,不发布;可加packageManager、pnpm.overrides等。
-
pnpm-workspace.yaml:- 唯一,只能放在根目录。
- 通过
packages数组声明哪些目录算 workspace 包(如packages/*、apps/*),只有这些才能被workspace:*引用。 - pnpm 官方推荐用这个文件,而不是
package.json的workspaces字段。
-
pnpm-lock.yaml:- 全 workspace 共用一个,在根目录。
- 锁死所有依赖(含 workspace 包解析结果),保证任意环境
pnpm install结果一致。
-
packages/*:- 一般放可复用库:组件库、工具库、配置包等。
- 各自有
package.json,通过workspace:*相互依赖或被apps/*依赖。
-
apps/*:- 一般放应用:前端项目、文档站、Demo 等。
- 依赖
packages/*时用workspace:*,改库即生效。
有的项目还会加 tools/* 放脚本、CLI 等,本质上一样:在 pnpm-workspace.yaml 里写上对应 glob 即可。
4.2 命名与布局约定
- packages :可复用、可能发布到 npm 的库;apps:入口应用、不发布或只发构建产物。
- 何时拆
apps?当你明确有「多个应用 + 共享 packages」时,拆开更清晰;只有一两个 app 时,全放packages也没问题,按团队习惯来。 - 依赖方向 :
- 子包互相依赖、app 依赖子包,一律用
workspace:*。 - 禁止循环依赖(A 依赖 B,B 又依赖 A),否则安装、构建都会出问题。
- 根包通常不作为业务依赖,只提供脚本和公共 devDependencies。
- 子包互相依赖、app 依赖子包,一律用
4.3 workspace 包的解析与匹配机制
靠啥匹配?
pnpm 解析 workspace:* 时,只看 package.json 里的 name ,和目录名、路径都无关。你写 "@my/ui": "workspace:*",pnpm 就会在 pnpm-workspace.yaml 声明的那堆目录里,找 name 为 @my/ui 的包;找到就把该包所在目录链进 node_modules,找不到就直接报错,不会悄悄去 npm 装一个。
具体流程:
- 读
pnpm-workspace.yaml,收集所有匹配packages的目录(如packages/*、apps/*); - 逐个读这些目录下的
package.json,拿到name,建成一张 「name → 目录」 的映射; - 解析依赖时,遇到
workspace:*、workspace:^等,用依赖里的包名去这张表里查; - 查到了 → 用该包所在目录 做链接目标,链到当前包的
node_modules里; - 查不到 → 报错(例如
ERR_PNPM_NO_MATCHING_PACKAGE),安装中止。
所以:包名必须和依赖里写的一模一样 。packages/ui 的 name 要是 @my/ui,别的地方才能 "@my/ui": "workspace:*";写成 @my/components 就匹配不上。
几种写法:
workspace:*:匹配 workspace 里同名包的任意版本,并链到源码目录;开发联调最常用。workspace:^、workspace:~:按 semver 匹配 workspace 内版本;发布时 会被替换成具体版本号(如1.0.0),发布出去的package.json里不会还带着workspace:。workspace:../packages/utils(相对路径):明确指向某个目录,不靠name匹配;适合临时调试或路径敏感的布局。
别名 :
可以用 "别名": "workspace:真实包名@*" 把 workspace 包挂到另一个名字下,例如 "react": "workspace:my-react@*"。发布时同样会替换成普通依赖形式。
找不到会怎样?
只会报错,不会回退到 npm 装。这样你才能确定:用的一定是本地的 workspace 包,没有误用远端的。
4.4 依赖图与构建顺序
workspace 里包和包之间的依赖关系 ,会形成一张有向图 :谁依赖谁,一目了然。pnpm 跑 pnpm -r run build 这类递归命令时,默认按这张图的拓扑顺序 执行:先跑被依赖的,再跑依赖别人的 ,避免「还没 build 完就被别人 require」的坑。
拓扑顺序是啥?
简单说:若 A 依赖 B,则一定先 执行 B 的 build,再 执行 A 的 build。例如 utils → ui → web,顺序就是 utils → ui → web。同一层之间(比如多个 app 互不依赖)谁先谁后不保证,但层级不会乱。
默认行为:
pnpm -r run build(以及pnpm -r run <script>):按依赖图拓扑排序 ,再依次执行;没有-r时则只跑当前包。pnpm -r --parallel run build:不管顺序 ,所有包并行 跑;跑dev、test时常用--parallel,但 build 一般要保证顺序,所以慎用--parallel。
怎么知道谁依赖谁?
- 看各包
package.json的dependencies/devDependencies里对 workspace 包、普通包的引用; - 用
pnpm why <pkg>看某包被谁依赖;pnpm list -r看全 workspace 的依赖树(注意list默认不按拓扑序,按字母序); - 有些团队会接 Turborepo、Nx 等,用它们画依赖图、跑拓扑并行 build(同一层并行,层与层之间仍按依赖顺序)。
循环依赖 :
若出现 A → B → C → A,依赖图成环,拓扑排序搞不定,pnpm 会报错;安装、-r 执行都可能挂。所以必须保证 workspace 内无环,设计时就要避免「包互相依赖」。
4.5 安装与打包:workspace 如何工作
安装(pnpm install)
在根目录 执行 pnpm install 时,大致会做这几步:
- 读 workspace 定义 :解析
pnpm-workspace.yaml,得到所有 workspace 包目录(如packages/*、apps/*)。 - 收集包信息 :逐个读这些目录下的
package.json,建 name → 目录 映射,并算出整棵依赖树(含对 npm 包的依赖)。 - 解析
workspace:*:遇到workspace:*等,按 4.3 的规则匹配到本地包目录,不从 registry 拉包。 - 链接 workspace 包 :把匹配到的本地包目录 链到各包的
node_modules里(符号链接或 junction),不拷贝、不往 store 塞;改源码立即生效。 - 装外部依赖 :对 npm 上的包,按平时那套来:store + 硬链接,装到
node_modules/.pnpm等位置。 - 写 lockfile :把所有依赖(含
workspace:*的解析结果)写入根目录的pnpm-lock.yaml。
所以:workspace 包只做链接,不占 store;占磁盘、耗时的主要是外部依赖,而它们仍走 store 复用。
打包 / 构建(pnpm -r run build)
构建不 改依赖安装方式,只是按依赖图顺序 跑各包的 build 脚本:
- 算依赖图 :根据各包
package.json的依赖关系,得到有向图。 - 拓扑排序 :排出「被依赖的在前、依赖别人的在后」的顺序(pnpm 内部用类似
graph-sequencer的方式处理)。 - 依次执行 :按该顺序对每个 workspace 包执行
pnpm run build(或你配的其它 script)。 - 若某包没有
build脚本,pnpm 会报错或跳过该包,视配置而定。
因此:先装依赖,再构建 ;装依赖保证 node_modules 里 workspace 包、npm 包都就位,构建则按依赖顺序生成各包产物。
若用 --parallel ,pnpm 会忽略拓扑顺序 ,所有包一起跑;适合 dev、test 等不严格要求「被依赖的先跑」的场景,但 build 一般别开 --parallel,否则可能用到尚未 build 的依赖。
和 Turborepo / Nx 的关系
pnpm 只负责依赖安装 + 按拓扑序跑 script ;缓存、增量构建、远程缓存 等,可交给 Turborepo、Nx。通常做法是:pnpm 管 install 和 workspace 链接,Turbo/Nx 管 build / test 的调度与缓存,两者一起用没问题。
五、优缺点一览(够直白版)+ 逐条详解
5.1 优点总览
| 点 | 说明 |
|---|---|
| 省磁盘、安装快 | 全局 store + 硬链接,避免重复存包;workspace 包用链接,不复制。 |
| 依赖干净 | 严格依赖,无幽灵依赖;lockfile 唯一,版本一致。 |
| 本地联调友好 | workspace:* 直接链到源码,改即生效,无需 npm link。 |
| monorepo 友好 | 内建 workspace 支持,-r、--filter 过滤、并行跑脚本很方便。 |
| 易于做权限与发布 | 配合 pnpm publish -r、changesets 做按包发布、权限控制。 |
详细说明:
- 省磁盘、安装快 :原理即第二节的 store + 硬链接;workspace 包不进 store,只做链接。典型收益是 monorepo 磁盘占用和
pnpm install耗时明显下降。 - 依赖干净 :严格依赖 + 单一 lockfile,少很多「删了某包就挂」「本地有 CI 没有」的玄学问题;注意若用了
public-hoist-pattern等,要控制范围,否则又引入隐性依赖。 - 本地联调 :改
packages/ui立刻在apps/web里生效,无需 link;注意跑 dev 的终端要在根目录 或对应 app 目录,且已执行过根目录的pnpm install。 - monorepo 友好 :
pnpm -r、--filter能力足,再配合 Turborepo/Nx 做任务编排、缓存,体验更好。 - 发布:按包发布、changesets 管理版本与 changelog,和现有 registry 流程兼容。
5.2 缺点 / 注意点总览
| 点 | 说明 |
|---|---|
| 和 npm 不完全兼容 | 部分工具假设「所有依赖扁平在根 node_modules」,可能报错,需适配。 |
| 学习与迁移成本 | 团队要搞懂 workspace、workspace:*、pnpm-workspace.yaml、--filter 等。 |
| 部分旧工具兼容性 | 极端老旧的构建/调试工具对 pnpm 的 node_modules 结构可能不友好。 |
| 需统一包管理 | 全 repo 必须用 pnpm,不能混用 npm/yarn,否则 lockfile、链接会乱。 |
详细说明:
-
和 npm 不完全兼容 :
有些 Webpack 插件、老版 Babel、个别 CLI 会直接去根
node_modules找包,pnpm 默认非扁平就可能找不到。处理办法:- 用
node-linker=hoisted(.npmrc)切回类 npm 扁平结构,会牺牲严格依赖; - 或只用
public-hoist-pattern把有问题的包提升上来,尽量窄配。
- 用
-
学习与迁移成本 :
团队至少要会:workspace 概念、
pnpm-workspace.yaml、workspace:*协议、根目录pnpm install、--filter与-r的用法。可以抽半小时过一遍本文 + 官方文档,再在试点项目跑一遍。 -
旧工具兼容性 :
建议先小范围试点,遇到具体工具再查 pnpm 兼容性 或社区 issue;大多数现代前端工具已支持。
-
统一包管理 :
全仓库只用 pnpm ,禁止
npm install/yarn。用packageManager锁版本,CI 里corepack enable && pnpm install,避免有人用错包管理器导致 lockfile 或链接关系错乱。
适合 :中大型前端项目、组件库 + 多应用、多包复用的 monorepo。
不大适合:单应用、没有多包复用需求的小项目;用 pnpm 单仓也能受益,但 workspace 收益有限。
六、应用场景(什么时候上 workspace?)
下面按场景拆:谁用、解决啥问题、推荐结构、关键配置、日常工作流。你对照自己项目,能直接套用或微调。
6.1 UI 组件库 + 多个业务项目
场景 :你们有一个业务组件库,要同时支撑 2~3 个前端项目;组件库频繁迭代,需要在各项目里即时验证,而不是先发 npm 再装。
推荐结构:
packages/
ui/ # 组件库
apps/
web-admin/
web-h5/
web-docs/ # 组件文档
web-admin、web-h5、web-docs 都依赖 @my/ui,用 workspace:*。
关键配置:
pnpm-workspace.yaml:packages: ['packages/*', 'apps/*']。- 各 app 的
package.json:"@my/ui": "workspace:*"。 - 根
scripts:如"dev:docs": "pnpm --filter web-docs run dev","build:ui": "pnpm --filter @my/ui run build"。
工作流 :
改 packages/ui → 在 apps/web-docs 或任意 app 里直接看效果;要发版时用 changesets 给 @my/ui 打 version、写 changelog、publish,各 app 再决定何时把 workspace:* 换成固定版本(若你们发 npm 的话)。
6.2 多应用 + 公共 utils / config
场景 :多条产品线、多个前端应用,共享 utils、api-client、eslint-config 等,希望统一版本、统一升级。
推荐结构:
packages/
utils/
api-client/
config-eslint/
apps/
app-a/
app-b/
apps 按需依赖 @my/utils、@my/api-client;config-eslint 被各 app 的 devDependencies 引用。
关键配置:
pnpm-workspace.yaml:同上。- 各包用
workspace:*互引;根package.json可放公共 devDependencies,或用 catalog 统一 React、TypeScript 等版本。 - 根脚本:
"build": "pnpm -r --filter './apps/*' run build",只构建 apps。
工作流 :
公共逻辑在 packages/* 改,各 app 自动用到;发版用 changesets 按包发布,各 app 通过 workspace:* 或固定版本消费。
6.3 文档站 + 组件库
场景 :组件库配套一个文档站(如 VitePress、Docusaurus),文档站要直接引用源码里的组件做 Demo,而不是已发布的 npm 包。
推荐结构:
packages/
ui/
apps/
docs/
docs 依赖 @my/ui,workspace:*。
关键配置:
- 同上,
packages+apps;docs里"@my/ui": "workspace:*"。 - 文档站构建配置里保证能解析
packages/ui的源码(通常 workspace 链接后没问题)。
工作流 :
改组件 → 跑 docs 的 dev,文档里实时看效果;发版时先发 @my/ui,再更新文档站里对版本的说明(若文档站自己也要发)。
6.4 全栈 monorepo(前后端同仓)
场景:前端 + Node 服务同仓,共享类型、常量或少量 utils,用同一套依赖管理。
推荐结构:
packages/
types/
shared-utils/
apps/
web/
api/ # Node 服务
api 和 web 都依赖 @my/types、@my/shared-utils,workspace:*。
关键配置:
pnpm-workspace.yaml包含packages/*、apps/*。- 根
package.json的scripts里分别--filter web、--filter api跑 dev/build。
工作流 :
改 types 或 shared-utils,前后端同时生效;各自部署时只构建对应 app,公共逻辑通过 workspace 链进去。
只要你存在「多个包 + 互相依赖 + 要一起开发」的需求,workspace 就很值得上;上面四种可以组合,比如「组件库 + 多应用 + 文档站」一起做。
七、详细教程:从零搭一个 pnpm workspace
下面按步骤做一遍,每步会写操作、预期结果、常见报错与排查。路径、包名和上文保持一致,你照抄就能跑通。
7.1 环境准备
-
安装 pnpm:
bashnpm install -g pnpm或用 Corepack(Node 16.9+):
bashcorepack enable corepack prepare pnpm@latest --activate建议用 pnpm 8.x 或 9.x ,Node 18+ 更省心。
-
校验:
bashpnpm -v node -v看到版本号即成功。
7.2 初始化根项目
bash
mkdir my-workspace && cd my-workspace
pnpm init
会生成根目录 package.json。编辑成类似:
json
{
"name": "my-workspace",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@9.0.0"
}
private: true:根包不会被pnpm publish发出去,避免误发。packageManager:锁死 pnpm 版本,配合corepack enable使用;可选但推荐。
7.3 配置 pnpm-workspace.yaml
在项目根目录 新建 pnpm-workspace.yaml:
yaml
packages:
- 'packages/*'
- 'apps/*'
packages/*:packages/下每个子目录(如packages/ui、packages/utils)都算一个 workspace 包。apps/*:同理。- 只有被列出来的目录才会被 pnpm 当成 workspace 成员,才能被
workspace:*引用。
预期 :保存后暂无输出;之后 pnpm install 时 pnpm 会扫描这些目录。
7.4 创建子包目录并初始化
bash
mkdir -p packages/ui packages/utils apps/web
然后逐个初始化(Windows 用户 可用 PowerShell,mkdir -p 若不可用就分步 mkdir):
bash
cd packages/utils && pnpm init && cd ../..
cd packages/ui && pnpm init && cd ../..
cd apps/web && pnpm init && cd ../..
(Windows :若 mkdir -p 报错,可改为 mkdir packages\ui、mkdir packages\utils、mkdir apps\web 等分步创建;cd ../.. 在 PowerShell 中同样适用。)
每个子包会多一个 package.json。接下来改包名、入口、exports。
packages/utils/package.json:
json
{
"name": "@my/utils",
"version": "0.0.1",
"main": "index.js",
"exports": {
".": "./index.js"
}
}
packages/ui/package.json:
json
{
"name": "@my/ui",
"version": "0.0.1",
"main": "index.js",
"exports": {
".": "./index.js"
}
}
apps/web/package.json:
json
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "echo \"dev placeholder\"",
"build": "echo \"build placeholder\""
}
}
exports:现代 Node 和打包器都认,用来明确入口,避免多余文件被引用;对 ESM、TS 等更友好。web的dev/build先占位,后面验证完 workspace 再换成真实命令。
7.5 用 workspace:* 做包间依赖
packages/ui/package.json 里加依赖 @my/utils:
json
{
"name": "@my/ui",
"version": "0.0.1",
"main": "index.js",
"exports": { ".": "./index.js" },
"dependencies": {
"@my/utils": "workspace:*"
}
}
apps/web/package.json 里加依赖 @my/ui:
json
{
"name": "web",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "echo \"dev placeholder\"",
"build": "echo \"build placeholder\""
},
"dependencies": {
"@my/ui": "workspace:*"
}
}
workspace:* 表示「用当前 workspace 里的同名包,追踪源码」;装完依赖后会链接到对应包目录,改代码即时生效。
7.6 根目录执行 pnpm install
务必在根目录 执行(若不在根目录,先 cd 到项目根):
bash
pnpm install
预期:
- 根目录出现
node_modules/、pnpm-lock.yaml; packages/ui、apps/web的node_modules里会有@my/utils、@my/ui的链接;- lockfile 里能看到对
workspace:的解析,例如:
yaml
packages:
'@my/utils@workspace:*':
resolution: { directory: packages/utils, type: directory }
'@my/ui@workspace:*':
resolution: { directory: packages/ui, type: directory }
(省略其他字段;实际 lockfile 还有 name、version 等。)
若报 ERR_PNPM_NO_MATCHING_PACKAGE :检查 pnpm-workspace.yaml 的 packages 是否包含对应目录,以及子包 name 是否和依赖里写的一致。
7.7 根 package.json 里加批量脚本
根目录 package.json 增加:
json
{
"name": "my-workspace",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@9.0.0",
"scripts": {
"dev": "pnpm -r --parallel run dev",
"build": "pnpm -r run build",
"build:web": "pnpm --filter web run build"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}
pnpm -r:递归在所有 workspace 包里执行同名 script。pnpm -r --parallel:并行跑,适合dev。pnpm --filter web run build:只对web包执行build。
Windows :若使用 PowerShell,scripts 里的双引号、&& 等和 Unix 略有差异,一般上述写法没问题;若遇解析错误,可改为 node 跑一小段脚本封装命令。
7.8 验证 workspace 链路
- 在
packages/utils/index.js写:
js
module.exports = { add: (a, b) => a + b };
- 在
packages/ui/index.js写:
js
const { add } = require('@my/utils');
module.exports = { add, hello: 'from ui' };
- 在
apps/web里加个临时脚本验证。给apps/web/package.json的scripts增加一行"run:check",例如:
json
"scripts": {
"dev": "echo \"dev placeholder\"",
"build": "echo \"build placeholder\"",
"run:check": "node -e \"const x=require('@my/ui'); console.log(x.add(1,2), x.hello)\""
}
保存后,在根目录执行:
bash
pnpm --filter web run run:check
预期输出 :3 'from ui'。
若 Cannot find module '@my/ui':
- 确认在根目录 执行过
pnpm install; - 确认
apps/web的dependencies里有"@my/ui": "workspace:*"; - 看看
apps/web/node_modules/@my下是否有ui的链接。
若 ENOENT 等路径类错误:
- 检查
packages/utils、packages/ui是否有index.js,以及package.json的main/exports是否指向它。
验证通过后,可以把 web 的 dev / build 换成真实命令(如 Vite、Next 等),继续开发。
八、配置说明(可查阅手册)
这一节把 pnpm workspace 相关配置 拆开讲:每项是啥、怎么配、适用场景、注意点。方便你以后查。
8.1 pnpm-workspace.yaml
- 唯一性 :整个仓库只放一个在根目录;pnpm 只认根目录这份。
packages:- 字符串数组,每个元素是一个 glob 或具体路径。
- 例:
'packages/*'、'apps/*'、'tools/*',或'packages/ui'、'packages/utils'。 - 只有匹配到的目录 且其中包含
package.json,才会被当作 workspace 包。
- 排除 :部分版本支持
!排除,如!'packages/legacy/*',以你用的 pnpm 文档为准。 - 与
package.json的workspaces:pnpm 官方推荐用pnpm-workspace.yaml定义 workspace,不用workspaces字段;若同时存在,以pnpm-workspace.yaml为准。
示例:
yaml
packages:
- 'packages/*'
- 'apps/*'
- 'tools/*'
8.2 根目录 package.json
-
private: true:根包不发布,避免误pnpm publish。 -
packageManager:如"pnpm@9.0.0",锁包管理器 + 版本;需corepack enable。 -
scripts:结合pnpm -r、--filter做批量或定向执行(见 8.6)。 -
pnpm.overrides:强制某依赖在全 workspace 解析成指定版本。json{ "pnpm": { "overrides": { "lodash": "4.17.21" } } }装依赖时 pnpm 会按 overrides 解析,并反映在 lockfile;适合修安全漏洞、解决传递依赖冲突。
-
catalog(pnpm 9+) :在pnpm-workspace.yaml里定义(不是 package.json),子包用catalog:引用;见下方示例。
catalog 示例 (pnpm-workspace.yaml):
yaml
packages:
- 'packages/*'
- 'apps/*'
catalog:
react: ^18.3.1
react-dom: ^18.3.1
子包 package.json:
json
{
"dependencies": {
"react": "catalog:",
"react-dom": "catalog:"
}
}
升级时只改 catalog 即可,所有用 catalog: 的包一起变。
8.3 workspace: 协议
-
workspace:*:用当前 workspace 里同名包 的任意版本 ,并链接到源码目录。开发联调默认用这个。 -
workspace:^、workspace:~:按 semver 匹配 workspace 内版本;发布时 pnpm 会把它替换成实际版本号 (如1.0.0),所以发布到 npm 的包不会还带着workspace:。 -
锁文件里的表现 :
yaml'@my/ui@workspace:*': resolution: { directory: packages/ui, type: directory }表示解析为本地
packages/ui目录。
日常开发 workspace:* 就够用 ;若你们有严格的 semver 约束再考虑 ^ / ~。
8.4 pnpm-lock.yaml
- 唯一 :整份 workspace 共用一个 lockfile,放在根目录。
- 内容:锁住所有依赖(含 workspace 解析结果)的版本、完整性校验等。
- 维护 :用
pnpm install、pnpm add等变更依赖,不要手改。 - CI :务必把
pnpm-lock.yaml纳入 git;CI 里pnpm install --frozen-lockfile可保证和 lockfile 完全一致,复现构建。
8.5 .npmrc(项目级)
放在项目根目录,只影响当前仓库。
常见项:
| 配置项 | 含义 | 示例 |
|---|---|---|
store-dir |
全局 store 路径 | store-dir=D:\pnpm-store |
node-linker |
链接方式 | isolated(默认)/ hoisted |
hoisted |
已废弃,用 node-linker |
--- |
public-hoist-pattern |
哪些包提升到根 node_modules |
public-hoist-pattern[]=*eslint* |
shamefully-hoist |
全部提升,类似 npm | true,易幽灵依赖,慎用 |
auto-install-peers |
自动装 peerDependencies | true |
strict-peer-dependencies |
peer 未满足时报错 | true |
node-linker=hoisted:切回类 npm 扁平结构;兼容性好,但失去严格依赖。public-hoist-pattern:只把匹配的包提升,例如 ESLint、Prettier 等工具常见需求;能窄就窄,减少幽灵依赖。resolution-mode:依赖解析策略(如lowest-direct);lockfile-include-tty等可按需查文档。
示例(只提升部分工具):
ini
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
8.6 --filter 完整语法
--filter 用来限定 要对哪些 workspace 包执行命令,常与 pnpm -r、pnpm add 等一起用。
| 写法 | 含义 | 示例 |
|---|---|---|
--filter <pkg> |
指定包(按 name 或路径) | pnpm --filter web run build |
--filter <pkg>... |
pkg 以及依赖了 pkg 的所有包(dependents) |
pnpm -r --filter '@my/ui...' run build |
--filter ...<pkg> |
pkg 以及被 pkg 依赖的所有包(dependencies) |
pnpm -r --filter '...web' run build |
--filter ...^<pkg> |
仅依赖了 pkg 的包,不含 pkg 自身 |
pnpm -r --filter '...^@my/ui' run test |
@scope/* |
通配,所有 @scope 下包 |
pnpm -r --filter '@my/*' run build |
示例:
bash
# 只给 web 装 lodash
pnpm add lodash --filter web
# 只给名字匹配 @my/* 的包跑 build
pnpm -r --filter '@my/*' run build
# 只给依赖了 @my/ui 的包跑 test(不含 @my/ui 自身,例如 web、docs)
pnpm -r --filter '...^@my/ui' run test
# 只给 web 及其依赖的 workspace 包跑 build(含 web 自身)
pnpm -r --filter '...web' run build
多 filter 可组合,例如 --filter '@my/ui...' --filter web 表示满足任一条件的包。仅要「依赖了某包」的包且排除该包本身 时,用 ...^<pkg>。
8.7 依赖提升(hoisting)
- 默认 :pnpm 不提升 ,依赖装在各自包的
node_modules或.pnpm下,严格隔离。 public-hoist-pattern:把匹配的包额外 提升到根node_modules,方便某些工具查找;提升范围越大,幽灵依赖风险越高。shamefully-hoist:几乎全部提升,和 npm 类似;不推荐,除非你只是临时兼容旧工具。
对比:
- 不提升:根
node_modules只有直接依赖,子依赖在.pnpm里,严格。 - 提升后:根
node_modules会出现被提升的包,未声明也可能被引用,所以要想清楚再开。
8.8 只用 pnpm / 锁包管理
- 全仓库统一用 pnpm ,禁止
npm、yarn,否则 lockfile 和链接会乱。 - 根
package.json设packageManager,如"pnpm@9.0.0"。 - 启用 Corepack :
corepack enable;CI 里先corepack enable再pnpm install,保证版本一致。
九、和 npm / Yarn workspace 的简单对比
| 能力 | npm workspaces | Yarn workspace | pnpm workspace |
|---|---|---|---|
| 磁盘占用 | 高,多份拷贝 | 一般 | 低,store+硬链接 |
| 安装速度 | 一般 | 较快 | 快 |
| node_modules 结构 | 扁平 | 扁平或 PnP | 非扁平,.pnpm |
| 幽灵依赖 | 易出现 | 有 | 默认严格,无 |
| lockfile 格式 | package-lock.json |
yarn.lock |
pnpm-lock.yaml |
| workspace 协议 | workspace:* 等 |
workspace:* 等 |
workspace:* 等 |
| 配置方式 | package.json workspaces |
package.json workspaces |
pnpm-workspace.yaml |
| filter/scripts | 无内置 filter | 有 workspaces 脚本 | -r、--filter 等 |
| CI 缓存友好度 | 一般 | 较好 | 好(store 可复用) |
何时选 pnpm workspace:
- 你打算认真搞 monorepo、多包复用,且关注磁盘、安装速度、依赖干净。
- 愿意统一用 pnpm,并接受一点学习与迁移成本。
何时继续用 npm / Yarn:
- 现有 npm/Yarn 脚本、CI 已经很成熟,团队不想动。
- 单仓库、包很少,workspace 收益有限,用 pnpm 单仓也不错,不必非上 workspace。
pnpm 的差异主要来自存储与解析策略,而不是「有没有 workspace」本身。
十、进阶与延伸
10.1 发版:按包发布 + changesets
pnpm publish -r:递归发布所有 未 private 的 workspace 包;可加--filter只发改动的,例如先pnpm -r --filter '@my/ui...' run build再pnpm publish -r --filter '@my/ui'。- changesets :
- 用
changeset管理 version bump 和 changelog; - 流程大致:改代码 →
pnpm changeset选包、选版本类型、写 changelog →pnpm changeset version更新版本号 →pnpm publish -r发布。
这样多包独立发版、可追溯,很常见。
- 用
10.2 任务编排:Turborepo / Nx
- 根
package.json的build、dev等可以交给 Turbo 或 Nx 跑:他们按依赖图做拓扑排序 ,只跑该跑的,且能做远程/本地缓存,加速 CI 和本地构建。 - pnpm workspace 只负责依赖安装与链接 ;Turborepo/Nx 负责任务调度,两者配合良好。
10.3 参考
- pnpm 官方文档
- pnpm workspace
- pnpm-workspace.yaml
- CLI:
pnpm --help、pnpm install --help、pnpm add --help等
十一、小结与 FAQ
11.1 小结
- 问题 :多包重复安装、幽灵依赖、本地联调麻烦、CI 又慢又占空间 → 本质是多包管理 + 依赖存储/解析没做好;pnpm workspace 针对这两点设计。
- 原理 :全局 store + 硬链接省空间、提速;非扁平
node_modules+ 严格依赖防幽灵依赖;workspace 包链到源码,改即生效。 - 架构 :根
pnpm-workspace.yaml+ 根package.json+ 唯一pnpm-lock.yaml+packages/*/apps/*;子包用workspace:*互引,禁止循环依赖。 - 配置 :弄清
pnpm-workspace.yaml、根package.json、workspace:协议、.npmrc常用项、--filter用法即可上手。 - 建议 :按第七节亲手搭一遍 ,再在一个小项目里拆一个
utils包用workspace:*引用,跑几天 dev/build,体感会很明显;后续再接 changesets、Turborepo 等。
11.2 FAQ
Q:子包的依赖装到根还是装到各自包?
A:各自 package.json 里声明,各自装 ;pnpm 会把实体放在 store、在对应包的 node_modules/.pnpm 下链接。根 package.json 只放全仓库共用的 devDependencies(如 TS、ESLint)和脚本。
Q:workspace:* 发布到 npm 前要改吗?
A:不用 。pnpm publish 时会把 workspace:* 等替换成实际版本号 再发布,发布出去的 package.json 里是普通版本范围。
Q:Windows 下路径或脚本有问题怎么办?
A:
- 路径尽量别带中文、空格;
store-dir等用正斜杠或系统可识别的形式。 - 若在 PowerShell 里
scripts报错,可试着用node写一个小脚本封装pnpm -r/--filter等命令,再在scripts里调该脚本。 - 全局 pnpm、Node 建议用官方安装包或 nvm-windows,避免权限、路径异常。
如果你有具体的目录结构或 package.json 想优化,可以贴出来,按你现在的项目一步步改也行。