用 pnpm workspace 从零搭一个 Monorepo

本节目标

pnpm 是目前最主流的 Monorepo 方案,它对「幽灵依赖」的隔离做得最好。我们从空目录开始。

# 运行环境:Node.js 18+ + pnpm 8+;全局装一次 pnpm 即可
# npm install -g pnpm

# 1) 建仓库骨架
mkdir my-monorepo && cd my-monorepo
pnpm init                 # 生成根 package.json

# 2) 声明「哪些是工作区的包」
# 根目录新建 pnpm-workspace.yaml

pnpm-workspace.yaml 告诉 pnpm:哪些目录算「包」。最常见写法:

# pnpm-workspace.yaml
packages:
  - 'packages/*'          # packages 下的每个子目录都是一个包
  - 'apps/*'              # 也可以拆 apps 和 packages
  # - '!**/test'          # 感叹号 = 排除

然后建两个包:ui(组件库)和 web(应用),各自 pnpm init 起一个 package.json,name 用 @my/ui、@my/web 这种 scope 命名,避免和公共包撞名。

# 3) 建子包
mkdir -p packages/ui packages/web
cd packages/ui && pnpm init && cd ../..
cd packages/web && pnpm init && cd ../..

# 4) 关键一步:让 web「内部依赖」ui,不需要发 npm
cd packages/web
pnpm add @my/ui@workspace:*    # workspace:* = 永远指向仓库里的本地 ui
cd ../..

workspace:* 是 pnpm 的魔法:它让 web 的 package.json 里写 "@my/ui": "workspace:*",安装时 pnpm 不会去 npm 拉,而是直接把本地 packages/ui 软链(symlink)过来。你改了 ui 的代码,web 立刻能看到,零发版。

# 5) 在仓库根目录一次性安装所有包
pnpm install

# 6) 运行某个包的脚本(用 --filter 指定)
pnpm --filter @my/web dev

pnpm install 在根目录跑一次,会按 workspace 拓扑把所有子包装好、互相链接好。后续你在 web 里 import { Button } from '@my/ui' 就能直接用本地组件。

名词解释

pnpm-workspace.yaml:pnpm 的「工作区清单」文件,用 packages 字段声明哪些目录属于这个 Monorepo。它是 Monorepo 的「地图」。

workspace:* 协议:声明「这个依赖指向仓库内的另一个本地包」,版本号用 * 表示「跟随本地最新」。安装时软链而非下载,实现跨包即时复用。

Symlink(软链接):pnpm 把本地包以链接形式放进依赖目录,逻辑上「像装了」、物理上「指向源码」。所以改源码即生效。

Scope(作用域):@my/ui 里的 @my 是组织名(scope),避免包名和 npm 公共包冲突,也方便统一发布到私有/公开 registry。

课后练习

练习 1:web 依赖 @my/ui 用了 workspace:*,如果把 ui 的版本从 1.0.0 升到 2.0.0,web 的 package.json 要不要跟着改?

答案:不用。workspace:* 的 * 表示「永远用本地最新」,不锁具体版本,ui 改版本号 web 这边零改动即可联动。

练习 2:pnpm install 应该在根目录跑,还是每个子包各跑一次?

答案:在根目录跑一次即可。pnpm 会按 workspace 拓扑统一解析、安装并链接所有子包;进子包单独跑反而可能破坏链接关系。

总结

搭 Monorepo 的核心就三样东西:根 package.json + pnpm-workspace.yaml 划包 + workspace:* 互链。这套组合拳让「跨包引用」从「发 npm 包等 CI」退化成了「改代码即生效」,正是 Monorepo 丝滑感的来源。记住 pnpm install 永远在根目录跑一次。下一节我们深入「共享包」——把类型、工具函数、ESLint 配置抽成全仓复用的公共包。