查看: 517|回复: 1

pnpm workspace 实战:从零搭建前端 monorepo,告别磁盘爆炸与幽灵依赖

[复制链接]
发表于 昨天 13:00 | 显示全部楼层 |阅读模式
本文面向有 monorepo 需求的前端团队,以 pnpm workspace 为主线,讲解其底层原理、架构设计、配置要点以及从零搭建的完整流程。所有操作均基于 pnpm 9+,适合直接落地实践。

一、为什么需要 pnpm workspace?

做前端 monorepo 时,最常遇到下面几类问题:

1. node_modules 膨胀,磁盘与安装时间双重浪费

用 npm 管理多个子包时,每个子包都会有一份自己的 node_modules,即使依赖版本相同也会重复拷贝。一个包含 5 个应用、3 个共享库的 monorepo,node_modules 轻松突破 2GB。npm install 首次全量安装需要几分钟,后续 npm ci 也因缓存命中率低而缓慢。

2. 幽灵依赖与版本冲突

npm 的扁平化提升会将子依赖提到根 node_modules,导致你可以在代码中直接 import 一个没有在 package.json 中声明的包(即幽灵依赖)。一旦上游依赖升级或移除该包,业务代码就会报错。同时,不同子包可能依赖同一个库的不同大版本,npm 只能满足一边,另一边运行时暴露问题。

3. npm link 本地联调体验差

用 npm link 在组件库和业务项目之间做本地联调时,经常出现双实例(React、Vue 等单实例要求)、bin 路径解析错误、不同 Node 环境行为不一致等问题。每次修改都需要反复 link/unlink,联调过程不可靠。

4. CI 构建慢且占用空间大

每次 CI 全量 npm install,没有 store 复用;缓存 key 设计不当导致命中率低,导致流水线耗时长、磁盘占用大。

这些问题的本质是两个方面:多包的组织与协作(项目结构 + 工作流),以及依赖的存储与解析(存储策略 + 解析方式)。pnpm workspace 正是同时解决这两类问题的方案。

二、pnpm 底层存储与 Node 模块结构

理解 pnpm 的存储模型和 node_modules 结构,是使用 workspace 的前提。

1. 全局 store:按内容寻址 + 硬链接

pnpm 有一个全局 store,所有安装过的包都先放入 store,再通过硬链接挂到各项目的 node_modules 中。store 默认路径:

Linux: ~/.local/share/pnpm/store
macOS: ~/Library/pnpm/store
Windows: %LOCALAPPDATA%\pnpm\store

可通过 .npmrc 中的 store-dir 覆盖。store 采用 content-addressable(按内容寻址),同一版本同一份包只存一份。硬链接是文件系统层面的多路径同一 inode,不额外占磁盘,且删除项目中的链接不会影响 store 中的原始文件(只要还有别的链接存在)。

2. node_modules 的非扁平结构

与 npm 的扁平化不同,pnpm 的 node_modules 结构是严格非扁平的。项目根目录的 node_modules 中只放你直接在 package.json 中声明的依赖,这些“包名”通常是符号链接,指向 node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>。而 .pnpm 目录中才是实际内容,每个包有自己的 node_modules,里面只装该包自己的依赖。子依赖不会被提升到根节点,因此无法在业务代码中 import 未声明的包,彻底杜绝幽灵依赖。

3. workspace 包的链接方式

当在 package.json 中写 "@my/ui": "workspace:*" 时,pnpm 会在 pnpm-workspace.yaml 中定义的目录范围内找到 name 为 @my/ui 的包,然后将该包的源码目录通过符号链接放入当前包的 node_modules 中。这样做的好处是:修改 packages/ui 的代码后,消费方(如 apps/web)立即可见,无需 npm link,也没有双实例问题。

4. 与 npm/Yarn 的存储对比

npm:扁平化 + 每项目各自拷贝,多项目多份,易出现幽灵依赖,安装速度与磁盘占用一般。
Yarn:经典模式类似 npm,Plug’n’Play 可选但生态兼容性有限。
pnpm:全局 store + 硬链接 + 非扁平 node_modules,省空间、安装快、默认严格依赖。

三、pnpm workspace 架构详解

1. 目录结构与职责

一个典型的 pnpm monorepo 根目录结构如下:

项目根目录
├── pnpm-workspace.yaml
├── package.json
├── pnpm-lock.yaml
├── .npmrc
├── packages/
│   ├── ui/
│   ├── utils/
│   └── config-eslint/
└── apps/
    ├── web/
    ├── docs/
    └── ...

根 package.json:放全仓库共用的 devDependencies(如 TypeScript、ESLint、Vitest),定义 scripts 使用 pnpm -r 或 --filter 批量执行子包任务,通常设置 "private": true。

pnpm-workspace.yaml:唯一,只能放在根目录,通过 packages 数组声明哪些目录算 workspace 包(如 packages/*、apps/*),只有这些才能被 workspace:* 引用。

pnpm-lock.yaml:全 workspace 共用一个 lockfile,保证环境一致性。

命名约定:packages 放可复用库(组件库、工具库、配置包),apps 放应用入口。依赖方向禁止循环依赖。

2. workspace 包的解析与匹配机制

pnpm 解析 workspace:* 时,只看 package.json 中的 name。它会在 pnpm-workspace.yaml 声明的目录中,找到 name 匹配的包,然后将该包所在目录链入 node_modules。找不到则直接报错,不会回退到 npm 安装。

支持几种写法:

workspace:*:匹配任意版本,链到源码目录,开发联调最常用。
workspace:^、workspace:~:按 semver 匹配,发布时会被替换为具体版本号。
workspace:../packages/utils(相对路径):明确指向目录,不靠 name 匹配。

别名写法:如 "react": "workspace:my-react@*",发布时同样会替换。

3. 依赖图与构建顺序

pnpm -r run build 会按依赖图的拓扑顺序执行:先跑被依赖的包,再跑依赖别人的包。例如 utils → ui → web 的顺序。如果使用 --parallel,则会忽略拓扑顺序并行执行,适合 dev 但 build 一般不用。

循环依赖会导致安装和构建失败,必须避免。

4. 安装与打包的工作原理

pnpm install 在根目录执行时:读取 pnpm-workspace.yaml,收集所有 workspace 包目录;读取各包 package.json 建立 name→目录映射;解析 workspace:* 匹配到本地包;将本地包目录链接到各包的 node_modules;外部依赖走 store+硬链接;最后写入 lockfile。

pnpm -r run build 则按依赖图拓扑排序后依次执行各包的 build 脚本。

四、优缺点及适用场景

优点:

省磁盘、安装快:全局 store+硬链接,workspace 包不进 store,只做链接。
依赖干净:严格依赖无幽灵依赖,锁文件唯一,版本一致。
本地联调友好:修改组件库源码后消费方立即可见,无需 link。
Monorepo 友好:-r、--filter 能力足,可配合 Turborepo/Nx 做任务编排与缓存。
发布方便:pnpm publish -r 按包发布,配合 changesets 管理版本。

缺点与注意点:

与 npm 不完全兼容:部分工具假设所有依赖扁平在根 node_modules,可能报错。可通过 .npmrc 中的 node-linker=hoisted 或 public-hoist-pattern 有限提升,但会引入幽灵依赖风险。
学习与迁移成本:团队需理解 workspace、workspace:*、pnpm-workspace.yaml、--filter 等概念。
旧工具兼容性:极老旧构建工具可能不友好,建议小范围试点。
必须统一包管理:全仓库只能用 pnpm,不能混用 npm/yarn,否则锁文件或链接会乱。建议在 package.json 中加 "packageManager": "pnpm@9.x" 并用 corepack 锁定。

适用场景:中大型前端 monorepo、组件库+多应用、多包复用项目。小项目单应用用 pnpm 单仓也能受益,但 workspace 收益有限。

五、从零搭建 pnpm workspace 实战

以下步骤基于 pnpm 9+,演示一个包含 packages/ui(组件库)和 apps/web(业务应用)的 monorepo。

1. 环境准备

安装 Node.js 18+,确保全局安装 pnpm:corepack enable && corepack prepare pnpm@latest --activate。

2. 初始化根项目

mkdir pnpm-workspace-demo && cd pnpm-workspace-demo
pnpm init

修改根 package.json,添加 "private": true,并设置 packageManager:
  1. {
  2.   "private": true,
  3.   "packageManager": "pnpm@9.15.0",
  4.   "scripts": {
  5.     "build": "pnpm -r run build"
  6.   }
  7. }
复制代码

3. 配置 pnpm-workspace.yaml

创建 pnpm-workspace.yaml,写入:
  1. packages:
  2.   - 'packages/*'
  3.   - 'apps/*'
复制代码

4. 创建子包目录并初始化

创建 packages/ui:

mkdir -p packages/ui && cd packages/ui
pnpm init

修改 package.json,将 name 设为 @my/ui,添加依赖(如 react、react-dom)并设置构建脚本。

同样创建 apps/web:

mkdir -p apps/web && cd apps/web
pnpm init

将 name 设为 @my/web,在 dependencies 中添加 @my/ui:
  1. {
  2.   "name": "@my/web",
  3.   "version": "1.0.0",
  4.   "dependencies": {
  5.     "@my/ui": "workspace:*"
  6.   }
  7. }
复制代码

5. 用 workspace:* 做包间依赖

在 apps/web 的 package.json 中写 "@my/ui": "workspace:*",表示引用 workspace 中同名的本地包。

6. 根目录执行 pnpm install

cd .. && pnpm install

该命令会安装所有外部依赖,并将 packages/ui 的源码链接到 apps/web 的 node_modules 中。

7. 根 package.json 加批量脚本

常用的脚本有:
  1. "scripts": {
  2.   "build": "pnpm -r run build",
  3.   "dev": "pnpm --filter @my/web run dev",
  4.   "lint": "pnpm -r run lint"
  5. }
复制代码

8. 验证 workspace 链路

查看 apps/web 的 node_modules 中 @my/ui 是否为符号链接指向 packages/ui,确认联调生效。

六、配置说明与常用技巧

1. pnpm-workspace.yaml

唯一且必需,只放 packages 数组。也可用 catalog 功能定义常用依赖版本,子包用 catalog: 引用,升级时只改一处。

2. 根 package.json

常用字段:scripts、devDependencies(公共工具)、pnpm.overrides(强制版本)、pnpm.peerDependencyRules.allowAny。

3. workspace: 协议

发布时会自动替换为实际版本号,如 workspace:^ 变为 ^1.0.0。

4. pnpm-lock.yaml

由 pnpm 自动维护,应提交到版本控制。

5. .npmrc(项目级)

常用配置:

store-dir=D:/.pnpm-store
node-linker=hoisted   # 若需要兼容旧工具
public-hoist-pattern[]=*vite*  # 只提升特定包

6. --filter 语法

常用模式:

pnpm --filter @my/web run dev   # 只运行特定包
pnpm --filter "packages/**" run test  # 全匹配
pnpm --filter "...{packages/**}" run build  # 包含依赖项

7. 依赖提升(hoisting)

默认 pnpm 不提升,如果需要兼容旧工具,可在 .npmrc 中设置 public-hoist-pattern 或 node-linker=hoisted。注意提升会带来幽灵依赖风险,应尽量窄配。

七、进阶与延伸

1. 发版:按包发布 + changesets

使用 changesets 管理版本与 changelog:pnpm changeset init → pnpm changeset → pnpm changeset version → pnpm publish -r。

2. 任务编排:Turborepo / Nx

pnpm 负责依赖安装和 workspace 链接,Turbo/Nx 负责 build/test 的调度与缓存,两者配合效果最佳。

八、常见问题(FAQ)

Q:执行 pnpm install 时报错 "ERR_PNPM_NO_MATCHING_PACKAGE"?
A:检查 pnpm-workspace.yaml 的 glob 是否匹配到对应包的目录,以及包名是否一致。

Q:如何查看某个 workspace 包被哪些包依赖?
A:使用 pnpm why <包名> -r。

Q:能同时使用 npm 吗?
A:不能,必须全仓库统一使用 pnpm,否则锁文件会混乱。

Q:根 package.json 需要写所有 workspace 包的名称吗?
A:不需要,根 package.json 只用于公共配置和脚本,各子包独立维护。

通过以上内容,你应该已经具备从零搭建 pnpm workspace 的完整知识。建议先在小项目试点,再推广到大型 monorepo 中。
回复

使用道具 举报

发表于 昨天 13:10 | 显示全部楼层

Re: pnpm workspace 实战:从零搭建前端 monorepo,告别磁盘爆炸与幽灵依赖

感谢分享!这篇实战文章把 pnpm workspace 的前因后果讲得很透,尤其是幽灵依赖和 store 硬链接的对比,对正在做 monorepo 迁移的团队非常有参考价值。想请教一下:在 CI 环境下,如果要让 pnpm store 跨流水线复用,你一般是怎么配置缓存 key 的?是按 lockfile 哈希还是按其他维度?另外,当 workspace 内部有多个包引用同一个外部依赖的不同大版本时(比如 react 17 和 18),pnpm 的非扁平结构能保证各自隔离,但 store 里会保留两份文件对吧?整体上会不会比 npm 多占一些空间?期待你的进一步经验~
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-6-14 03:08 , Processed in 0.025237 second(s), 17 queries , Gzip On, Redis On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部