浅析pnpm的monorepo

523 阅读5分钟

前言

Monorepo这个词你应该不止一次听说了,像Vue3、Vite、ElementPlus等优秀开源项目都是使用Monorepo的方式管理项目,且这里说到的这几个项目都是采用pnpm作为包管理工具。

可见 基于 pnpm 的 Monorepo大势所趋,本文就是讲解如何使用pnpm构建一个简单的Monorepo方式管理的项目。

两个疑问

Q:什么是 Monorepo

A:Monorepo是一种项目管理方式,就是把多个项目放在一个仓库里面,可以参考神三元大佬的一篇文章:现代前端工程为什么越来越离不开 Monorepo?,这篇文章中介绍了Monorepo的概念、收益以及MulitRepo的弊端。

Q:什么是pnpm?

A:pnpm就是一个包管理工具,原生支持Monorepo,比npm和yarn更快一些,其他的可以参考官网和神三元大佬的另一篇文章:为什么现在我更推荐 pnpm 而不是 npm/yarn?

搭建流程

首先需要安装 pnpm,需要你的Node.js版本大于14.xx,安装最新的就行了。

npm i pnpm -g

第一步,开始创建我们的项目,例如我的项目叫 pnpm-monorepo,命令如下:

mkdir pnpm-monorepo

第二步,初始化 package.json ,命令如下:

pnpm init

小小修改一个 package.json配置,让 node 命令运行的时候使用 esm 规范运行

{
  "name": "pnpm-monorepo",
  "version": "1.0.0",
  "description": "",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "MIT"
}

第三步,创建pnpm-workspace.yaml文件,这个文件定义了工作空间的根目录,内容如下:

packages:
  - 'packages/**'

然后,我们在packages中创建多个项目了,目录结构如下:

pnpm-monorepo
├── package.json
├── packages
│   ├── components
│   │   ├── index.js
│   │   └── package.json
│   ├── utils
│   │   ├── index.js
│   │   └── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml

第四步,编写每个项目的package.json,其实主要是编写一下名称,方便以后使用,这里我的如下:

{
  "name": "@packages/components",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [],
  "author": "",
  "type": "module",
  "license": "ISC",
  "dependencies": {
    "lodash": "^4.17.21",
  }
}

剩余的@packages/utils copy 一份,改一下 name 字段。

全局安装

一般是用 全局的公共依赖包,比如打包涉及到的 rolluptypescript

pnpm 提供了 -w, --workspace-root 参数,可以将依赖包安装到工程的根目录下,作为所有 package 的公共依赖。

例如装一个 axios 的包到全局的生产依赖,命令如下:

pnpm add axios -w

如果是一个开发依赖的话,可以加上 -D 参数,表示这是一个开发依赖,会装到 pacakage.json 中的 devDependencies 中,比如:,命令如下:

pnpm add axios -wD

局部安装

局部安装有两种方式

  1. 进入到对应项目,使用 pnpm i xxx
  2. 使用 --filter 指定对应项目进行安装

这里主要讲指定项目安装的方式,命令如下:

命令如下:

pnpm --filter <package_selector> <command>
// 缩写
pnpm -F <package_selector> <command>
  • package_selector package.json对应的name 字段
  • command 要执行的命令,可以是 安装、打包等命令

例如我们需要在@packages/components安装lodash,命令如下:

pnpm -F @packages/components add lodash

再往@packages/utils中安装一个dayjs,命令如下:

pnpm --filter @packages/utils add dayjs

项目之间引用

现在我们就来实现package间的相互引用,首先我们在@packages/utils/index.js中写入如下内容:

import dayjs from 'dayjs'
export function format(time, f = 'YYYY-MM-DD') {
  return dayjs(time).format(f)
}

然后我们执行如下命令:

pnpm -F @packages/components add @packages/utils@*

这个命令表示在@packages/components安装@packages/utils,其中的@*表示默认同步最新版本,省去每次都要同步最新版本的问题。

安装完成后@packages/components/package.json内容如下:

{
  "name": "@packages/components",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": [],
  "author": "",
  "type": "module",
  "license": "ISC",
  "dependencies": {
    "@packages/utils": "workspace: *",
    "lodash": "^4.17.21"
  }
}

然后我们在@packages/components/index.js写入如下内容:

import { format } from '@packages/utils'
console.log(format(new Date()))

然后我们在项目根目录运行如下命令

node packages/components

即可打印出当前的日期。

只允许pnpm

当在项目中使用 pnpm 时,如果不希望用户使用 yarn 或者 npm 安装依赖,可以将下面的这个 preinstall 脚本添加到工程根目录下的 package.json中:

{
  "scripts": {
    "preinstall": "npx only-allow pnpm"
  }
}

preinstall 脚本会在 install 之前执行,现在,只要有人运行 npm installyarn install,就会调用 only-allow 去限制只允许使用 pnpm 安装依赖。

Release工作流

workspace 中对包版本管理是一个非常复杂的工作,遗憾的是 pnpm 没有提供内置的解决方案,一部分开源项目在自己的项目中自己实现了一套包版本的管理机制,比如 Vue3Vite 等。

pnpm 推荐了两个开源的版本控制工具:

这里我采用了 changesets 来做依赖包的管理。选用 changesets 的主要原因还是文档更加清晰一些,个人感觉上手比较容易。

按照 changesets 文档介绍的,changesets主要是做了两件事:

Changesets hold two key bits of information: a version type (following semver), and change information to be added to a changelog.

简而言之就是管理包的version生成changelog

配置changesets

  • 安装
$ pnpm add -Dw @changesets/cli
  • 初始化
$ pnpm changeset init

执行完初始化命令后,会在工程的根目录下生成 .changeset 目录,其中的 config.json 作为默认的 changeset 的配置文件。

修改配置文件如下:

{
  "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "linked": [["@packages/*"]],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": [],
  "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
      "onlyUpdatePeerDependentsWhenOutOfRange": true
  }
}

功能配置说明,如下:

  • changelog: changelog 生成方式
  • commit: 不要让 changesetpublish 的时候帮我们做 git add
  • linked: 配置哪些包要共享版本
  • access: 公私有安全设定,内网建议 restricted ,开源使用 public
  • baseBranch: 项目主分支
  • updateInternalDependencies: 确保某包依赖的包发生 upgrade,该包也要发生 version upgrade 的衡量单位(量级)
  • ignore: 不需要变动 version 的包
  • ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH: 在每次 version 变动时一定无理由 patch 抬升依赖他的那些包的版本,防止陷入 major 优先的未更新问题

如何使用changesets

一个包一般分如下几个步骤:

为了便于统一管理所有包的发布过程,在工程根目录下的 pacakge.jsonscripts 中增加如下几条脚本:

  1. 编译阶段,生成构建产物
{
  "build": "pnpm --filter=@packages/* run build"
}
  1. 清理构建产物和 node_modules
{
  "clear": "rimraf 'packages/*/{lib,node_modules}' && rimraf node_modules"
}
  1. 执行 changeset,开始交互式填写变更集,这个命令会将你的包全部列出来,然后选择你要更改发布的包
{
  "changeset": "changeset"
}
  1. 执行 changeset version,修改发布包的版本
{
  "version-packages": "changeset version"
}

这里需要注意的是,版本的选择一共有三种类型,分别是 patchminormajor,严格遵循 semver 规范。

这里还有个细节,如果我不想直接发 release 版本,而是想先发一个带 tagprerelease版本呢(比如beta或者rc版本)?

这里提供了两种方式:

  • 手工调整

这种方法最简单粗暴,但是比较容易犯错。

首先需要修改包的版本号:

{
  "name": "@qftjs/monorepo1",
  "version": "1.0.2-beta.1"
}

然后运行:

$ pnpm changeset publish --tag beta

注意发包的时候不要忘记加上 --tag 参数。

  • 通过 changeset 提供的 Prereleases 模式

    利用官方提供的 Prereleases 模式,通过 pre enter <tag> 命令进入先进入 pre 模式。

常见的tag如下所示:

名称功能
alpha是内部测试版,一般不向外部发布,会有很多Bug,一般只有测试人员使用
beta也是测试版,这个阶段的版本会一直加入新的功能。在Alpha版之后推出
rcRelease Candidate) 系统平台上就是发行候选版本。RC版不会再加入新的功能了,主要着重于除错
$ pnpm changeset pre enter beta

之后在此模式下的 changeset publish 均将默认走 beta 环境,下面在此模式下任意的进行你的开发,举一个例子如下:

# 1-1 进行了一些开发...
# 1-2 提交变更集
pnpm changeset
# 1-3 提升版本
pnpm version-packages # changeset version
# 1-4 发包
pnpm release # pnpm build && pnpm changeset publish --registry=...
# 1-5 得到 1.0.0-beta.1

# 2-1 进行了一些开发...
# 2-2 提交变更集
pnpm changeset
# 2-3 提升版本
pnpm version-packages
# 2-4 发包
pnpm release
# 2-5 得到 1.0.0-beta.2

完成版本发布之后,退出 Prereleases 模式:

$ pnpm changeset pre exit
  1. 构建产物后发版本
{
  "release": "pnpm build && pnpm release:only",
  "release:only": "changeset publish --registry=https://registry.npmjs.com/"
}

一些方便的命令

  1. 清理所有 node_modules
{
    "clear": "rimraf packages/*/{lib,node_modules} && rimraf node_modules"
}   
  1. 安装所有依赖
{
	"install-all": "pnpm install --filter="@packages/*" && pnpm install"
}
  1. 执行所有打包命令
{
    "build": "pnpm --filter=@packages/* run build"
}