Pnpm 构建 Monorepo
最近开始搭建 monorepo 的项目,了解到以下方案:
- lerna 有强大完善的依赖管理和构建、打包发布流程,而且 v5 版本将 nx 与 lerna 整合了,提升了性能和速度
- 业界大多使用 lerna + yarn workspace 的方式来管理,yarn 用来管理依赖,lerna 用于管理发布
之前简单使用过 lerna ,不过再加上 yarn workspace , 学习容多、配置多又得折腾,太费劲🤪,暂时搁置。
最终尝试使用单一的 pnpm workspace
+ changesets
的方式来构建 monorepo,本身我就使用 pnpm 作为包管理工具,目前我的场景简单,pnpm 就已足够。
本文记录自己使用 pnpm workspace 搭建 monorepo 来管理多个脚手架的项目实践,👉 项目属于 nodejs 项目,非前端 UI 项目,开发环境如下:
- pnpm workspace 管理 monorepo 的所有依赖
- 提供 typescript 的开发环境
- 使用 es module 开发
初始化
新建工程目录 pnpm-monorepo,并将项目初始化 pnpm 管理,git 初始化 repo
pnpm init && git init
新建 pnpm-workspace.yaml
配置如下,通过 glob
语法将 packages 下的文件夹当做子 package 管理
packages:
- 'packages/**'
在 packages 文件夹中新建几个子 package 如下
├── package.json
├── packages
│ ├── app
│ ├── lib
│ └── log
└── pnpm-workspace.yaml
分别在 app、lib、log 这些子 package 目录执行 pnpm init ,👉 并且将子 package 中的 name 字段改为以后要发布到 npm 上的包名,示例如下:
{
"name": "@hey-cli/app",
"version": "0.0.1",
"description": "@hey-cli脚手架入口文件",
"main": "build/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "CaptainJack",
"license": "ISC"
}
这种重复新建子 package,需要新建文件夹,重复 pnpm init 并且修改 name 的操作,挺繁琐的,如何优化?
常用命令
--filter 指令,功能很强。--recursive 是个递归执行的执行
pnpm add 安装依赖
pnpm add
: 通过 pnpm -h
命令可以查看 add 命令可搭配的详细参数
workspace 顶层安装依赖
pnpm add typescript -w
在指定的子 package 中安装依赖,使用强大的 --filter 命令,其中 @hey-cli/app 为子package 的 name 字段
pnpm add commander --filter @hey-cli/app -D
pnpm remove 移除依赖
pnpm remove commander -D
pnpm link 在子 package 建立依赖
如果需要安装线上 npm 的依赖包,直接 pnpm add 即可,如果是本地子 package 相互依赖引用,需要使用 pnpm link 来建立依赖。
在子 package @hey-cli/app
中安装本地依赖 @hey-cli/log
,workspace 的 monorepo 模式下,pnpm 会优先链接到本地 package ,比如安装 @hey-cli/log
时,pnpm 先查找到 pnpm-workspace.yaml
文件中 配置的 packages 中就有 @hey-cli/log
这个包,就会直接使用这个本地依赖
pnpm add @hey-cli/log --filter @hey-cli/app
注意此处的 workspace 标识,代表是从子 package 中链接的本地包
"dependencies": {
"@hey-cli/log": "workspace:^0.0.1"
}
当执行 pnpm publish 时,上述 workspace:^0.0.1
依赖会自动变成 ^0.0.1
,即线上依赖的形式,在发布环节会具体介绍
执行子 package script 脚本
⚠️注意,不可以在子 package 路径下,直接运行命令,这样会无法使用到顶层的公共项目依赖。
pnpm 也不需要携带 run 来执行命令,直接执行命令本身就行,如 pnpm start
在子 package.json 文件中新增一个 start 命令
// package.json
"scripts": {
"start": "echo \"这是子 package @hey-cli/app 的 script 脚本\""
},
单独运行子 package 的脚本
使用 --filter 指令来指定子 package 包 @hey-cli/app
来运行脚本
🚜 CaptainJack # ~/Temp/pnpm-monorepo 🌵 Git > master 🤖 [12:18:58]
👉 pnpm --filter @hey-cli/app start
> @hey-cli/[email protected] start /Users/pearl/Temp/pnpm-monorepo/packages/app
> echo "这是子 package @hey-cli/app 的 script 脚本"
这是子 package @hey-cli/app 的 script 脚本
--recursive 来运行所有的子 package 的命令
--recursive 可以简写为 -r ,支持递归执行子 package 的命令
🚜 CaptainJack # ~/Temp/pnpm-monorepo 🌵 Git > master 🤖 [12:19:06]
👉 pnpm -r start
Scope: 3 of 4 workspace projects
packages/lib start$ echo "这是子 package @hey-cli/lib 的 script 脚本"
│ 这是子 package @hey-cli/lib 的 script 脚本
└─ Done in 12ms
packages/app start$ echo "这是子 package @hey-cli/app 的 script 脚本"
│ 这是子 package @hey-cli/app 的 script 脚本
└─ Done in 7ms
提供 typescript + es module 支持
安装配置 typescript
安装到顶层 workspace 的 devDependencies 中,顶层 workspace 中的依赖,子 package 中能直接使用
pnpm add typescript @types/node ts-node -D -w
新建一个通用的 tsconfig.json
,其他具体 packages 子包可以基于这个通用 tsconfig.json
来做扩展,一定要开启 esModuleInterop
选项, typescript 中支持通过 esModuleInterop 配置 es module 导入 CJS 这种情况时引入额外的辅助函数,进一步对兼容性进行支持。
{
"compilerOptions": {
"module": "ESNext",
"target": "ES2020",
"esModuleInterop": true,
"moduleResolution": "node",
"declaration": true,
"noImplicitAny": true,
"removeComments": true,
"noLib": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true,
"lib": ["es6"]
},
"exclude": ["node_modules", "**/*.spec.ts"]
}
配置 es module
在子 package 的 package.json 中,设置 "type": "module" ,代表其是 es module 项目
{
"type": "module",
"scripts": {
"start": "ts-node-esm src/index.ts"
},
}
此处必须使用 ts-node-esm
来提供对 es module 的支持,不能用 ts-node
命令。
构建
对于大型项目,有复杂构建的场景的,好像需要考虑云构建、缓存、并发等要素,估计我也没有到这种公司和项目中来。而且 pnpm 也可以集成 nx 来做构建,需要的话再来做笔记
changeset 版本管理
pnpm 的 publish 命令只能发布单个 npm 包,并不像 lerna 自带交互集成式的 monorepo 的 publish 的功能,所以需要补全一部分发布的工作流:bump version + changelog。
pnpm 官方推荐了 changesets 和 微软的 rush 两种工具用来管理版本,在 npm trends 上,rush 比 changesets 使用的更多,但是 rush 比较复杂,我的个人项目,选择使用 changesets 即可,虽然在工作流上比 lerna 多了一步添加 changeset 的过程,但是可以更加灵活的定制发布的工作流倒也不错
🎉最终步骤(基于pnpm 7.18.2 版本): 参考 Using Changesets with pnpm
pnpm run build
🛠本地开发了一个功能,执行自定义的 build 脚本,或者后续选择对接 github actions 之类的云端构建,那么这一步本地构建就可以省略pnpm changeset
记录变更集pnpm changeset version
提升版本,生成 changelogpnpm install
会更新 lockfile 以及 rebuild packagesgit commit xxx
pnpm publish -r
发布 workspace 中所有出现版本变更的包
初始化
安装依赖
pnpm add @changesets/cli -w -D
changeset 初始化配置
pnpm changeset init
会在根目录新建 .changeset
目录用来管理,其中的 config.json 是 changeset 的配置文件
├── .changeset
│ ├── README.md
│ └── config.json
changeset config.json 配置说明
- changelog: changelog 生成方式
- commit: publish 时 changeset 是否先执行 git add 操作
- linked: 配置需要共享版本的包
- access: 内网使用 restricted ,开源使用 public
- baseBranch: 项目主分支
- updateInternalDependencies: 确保某包依赖的包发生 upgrade,该包也要发生 version upgrade 的衡量单位(量级)
ignore: 不需要变动 version 的包 - ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH: 在每次 version 变动时一定无理由 patch 抬升依赖他的那些包的版本,防止陷入 major 优先的未更新问题
执行 pnpm changeset
执行 pnpm changeset
会将 packages
的包全部列出来,选择需要发布的包即可,选择时使用空格键选择。这一步是收集版本发布升级的信息的,并不是真正的版本升级。
需要发布的包版本类型可以使用 semver
规范,有 patch
, minor
, major
,这一步并不会修改到子 package 的 version ,而是把版本提升的信息记录到 .changeset
文件夹中的 md
文件中。ps: changset 同样提供了预发布 prerelease 的模式,常见的如 alpha/beta/rc 等,具体使用查看文档
每次 pnpm changeset
,都会在 .changeset
文件夹生成一个随机名称的 .md 文件用以描述版本和提交信息,这些 md 文件,都会在 pnpm changeset version 作为素材被添加到 changelog.md
文件中然后被 changeset 移除🤩
执行 pnpm changeset version
这一步是升级版本,会使用 .changeset
目录中刚才生成的 .md 文件内容,修改到子 package 的 package.json 的 version 字段,即对版本进行了提升,并且在发布的包的目录生成一个 changelog.md
文件,然后将上述 .changeset
文件夹中的这些记录版本信息的 md 文件都删除掉
执行 pnpm changeset publish
将上一步的版本变更发布到 npm 上
最终 package.json 配置实例
顶层 package.json 配置
{
"name": "hey-cli",
"version": "0.0.1",
"description": "CaptainJack 的专用脚手架",
"scripts": {
"start": "pnpm -r --parallel --filter='./packages/*' run start",
"build": "pnpm -r --filter='./packages/*' run build",
"release": "pnpm run build && pnpm publish -r"
},
"author": "CaptainJack",
"license": "ISC",
"devDependencies": {
"@changesets/cli": "^2.25.2",
"@types/node": "^18.11.15",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
}
}
子 package.json 配置
{
"name": "@hey-cli/app",
"version": "1.0.2",
"type": "module",
"description": "hey-cli入口包",
"main": "build/index.js",
"module": "build/index.js",
"scripts": {
"start": "ts-node-esm src/index.ts",
"tsc": "tsc"
},
"publishConfig": {
"access": "public", // 当使用npm 组织形式发布时,默认会是 private ,此处需改成 public ,否则发布失败
},
"author": "CaptainJack",
"license": "ISC",
"dependencies": {
"@hey-cli/log": "workspace:^0.0.1"
}
}
参考文章
本文是 22 年学习 monorepo 的时候,沉淀的文章,当时使用 monorepo 开发了自己的一套命令行