基于 pnpm + changesets 的 monorepo 最佳实践

6,913 阅读8分钟

前言

最近在设计 ICEPKG 多组件包管理的 Monorepo 方案,发现目前社区上有很多不同的 monorepo 方案,包括 pnpmlernanxturborepo 等等。其中,使用 pnpm 搭建 monorepo 项目是目前较为简单的一种方案,结合 changesets 可以轻松完成包的版本管理和发布。为了可以帮助更多想搭建 monorepo 项目的同学,编写此文记录搭建 monorepo 项目过程。

本文是属于实战性质的文章,不会过多介绍 monorepo 背景和原理,如果你想了解更多关于 monorepo 内容,推荐查看文章《Monorepo 是什么,为什么大家都在用?》《Monorepo 的过去、现在、和未来》

知识准备

笔者认为一个好的 monorepo 方案应该要解决以下两个问题:

  1. 自动根据包之间的依赖拓扑关系完成 link 和 build
  2. 包的版本管理以及发布

下面要介绍的两个工具正是用来分别解决上面的两个问题。

pnpm

pnpm 作为后起之秀的包管理工具,pnpm 内置支持了 Workspace 功能,帮助我们更轻松完成包之间的 link 和 build,更好管理 monorepo 项目。除此以外,它还有三点优势:

  1. 独特的依赖关系处理机制很好的解决了一直让人诟病的幽灵依赖的问题
  2. 利用硬链接的机制加快依赖安装速度
  3. 强大的 filter 机制可以筛选出指代的 package 或者基于 git 变更进行特定的操作

因此近些年很多开源项目都选择使用 pnpm,比如 ice.jsnext.js 等等。

changesets

changesets 是一个在 Monorepo 项目下进行版本管理和发布和 Changelog 文件管理的工具。changesets 会根据当前分支基于主分支的变化,筛选出需要变更的包,然后开发者可以根据实际场景更新包版本(遵循 semver 规范),填写 Changelog 信息,最后发布变更的包。

目前 pnpm、nx、turborepo 也推荐使用 changesets 来管理版本号和 Changelog,有很多开源项目也都在使用 Changesets,比如 pnpmmidwayjs-hooks 等等。

初始化 monorepo 项目

由于我们后面都会使用 pnpm,因此需要确保已安装 pnpm,并确保版本是 6 或以上。

为了让大家可以更快地搭建一个 monorepo 项目,我准备了一个模板给大家参考。其中,main 分支上的是最终的代码,大家可以直接拿来使用即可;pre 分支上只有最基本的代码,适合从 0 开始搭建 monorepo 项目。为了便于文章讲解,下面我们会从 pre 分支开始搭建。

git clone https://github.com/luhc228/pnpm-changsets-monorepo-example.git
cd pnpm-changsets-monorepo-example
git checkout pre

模板的目录结构如下:

pnpm-changsets-monorepo-example
├── LICENSE
├── package.json
├── packages
|  ├── a
|  |  ├── CHANGELOG.md
|  |  ├── index.ts
|  |  └── package.json
|  ├── b
|  |  ├── CHANGELOG.md
|  |  ├── index.ts
|  |  └── package.json
|  └── c
|     ├── CHANGELOG.md
|     ├── index.ts
|     └── package.json

其中,packages 目录放置三个子包,分别是 pkg-apkg-bpkg-c,其中 pkg-apkg-b 的子依赖 。

{
  "name": "pkg-b",
  "dependencies": {
    "pkg-a": "^0.0.0"
  }
}

使用 pnpm 管理依赖

在前面的章节中已经提及到了,我们将会使用 pnpm 来完成依赖的安装、依赖间的 link 和 build。

创建 workspace

首先我们先在项目根目录中新建 pnpm-workspace.yaml 文件并加入以下内容:

packages:
  - 'packages/*'

pnpm-workspace.yaml 里面我们声明了 packages 目录下的子目录都会被加入到 workspace 中,那么 pnpm 将根据会在 workspace 中子包的依赖关系,自动链接这些子包。比如上述的例子会将 pkg-a@0.0.0 链接到 pkg-b。现在让我们在项目根目录执行 pnpm install 看一下效果:

image.png

这里在 b/node_modules 目录下可以看到,pkg-a 已经自动 link 到 pkg-b 下了。

构建 package 产物

依赖安装好了以后,我们需要对子包进行构建。在项目根目录下执行 pnpm run build 来对每个子包进行构建:

image.png

可以看到,pkg-apkg-c 先执行 build 命令,等他们执行完成后,pkg-b 再执行 build。

为什么执行 pnpm run build 就变成这样呢?其实在项目根目录的 package.json 中预先写好了 build 脚本:

{
    "scripts": {
      "build": "pnpm -r run build"
    },
}

加入 -r 是指定为 worksapce 中的子包执行 build 命令。默认情况下,pnpm 会根据子包的依赖拓扑排序,按顺序对子包执行命令,以避免在构建某个包的时候,出现子依赖的构建产物未生成的问题,进而引发比如类型错误等问题。另外如果两个子包没有依赖关系,pnpm 会并发进行构建。

监听 package 变更

在项目根目录下执行 pnpm run watch,以对每个子包执行 watch 命令监听文件的变更以生成最新的构建产物。

image.png

pnpm run watch 对应在项目根目录的 package.json 中 watch 脚本:

  "scripts": {
    "watch": "pnpm --parallel -r run watch",
  },

watch 命令是会长时间运行监听文件变更,进程不会自动退出(除了报错或者手动退出),因此需要加上 --parallel 告诉 pnpm 运行该脚本时完全忽略并发和拓扑排序。

使用 changesets 管理包版本和发布

安装和初始化 changesets

首先,我们需要安装 changesets。我们在项目根目录执行下面的命令:

pnpm i -Dw @changesets/cli

安装完成以后,你可以在项目根目录执行以下命令以快速初始化 changesets:

pnpm changeset init

这时候,你会发现,项目根目录下多了一个 .changeset 目录,其中 config.json 是 changesets 的配置文件。请注意,我们需要把这个目录一起提交到 git 上。

image.png

发布第一个版本

在模板中,pkg-apkg-bpkg-c 三个包的版本号都是 0.0.0,我们可以在项目根目录下直接运行 pnpm changeset publish 为三个包发布第一个版本。发布完成后,我们完成了 monorepo 项目的初始化,我们可以把这个改动合并到你的主分支上并提交到远程仓库中。

生成 changeset 文件

假设现在要进行一个迭代,我们从主分支上切一个 release/0.1.0 分支出来。我们在 packages/a/index.ts 文件中随便添加一行代码,并提交到远程仓库。

pkg-a 代码发生变更了,我们需要发一个版本给用户使用。这时候我们在项目根目录下执行以下命令来选择要发布的包以及包的版本类型(patchminorminor,严格遵循 semver 规范):

pnpm changeset

changeset 通过 git diff 和构建依赖图来获得要发布的包。我们选择发布 pkg-a

image.png

我们选择更新到 minor 版本:

image.png

填写 changelog:

image.png

这时候,会发现多出来一个文件名随机的 changeset 文件:

image.png

这个文件的本质是对包的版本和 Changelog 做一个预存储,我们也可以在这些文件中修改信息。随着不同开发者进行开发迭代积累,changeset 可能会有多个的,比如 pnpm 仓库:

image.png

这些 changeset 文件是需要一并提交到远程仓库中的。在后面的包发布后,这些 changeset 文件是会被自动消耗掉的。

发布测试版本

假设现在我们要发布一个测试的版本来看下功能是否正常 work,我们可以使用 changeset 的 Prereleases 功能。

通过执行 pnpm changeset pre enter <tag> 命令进入先进入 pre 模式。

pnpm changeset pre enter alpha   # 发布 alpha 版本
pnpm changeset pre enter beta    # 发布 beta 版本
pnpm changeset pre enter rc      # 发布 rc 版本

这里我运行第二条命令,选择发布 beta 版本。

然后执行 pnpm changeset version 修改包的版本:

image.png

image.png

可以看到 pkg-a 的版本改成了 0.1.0-beta.0pkg-b 依赖的 pkg-a 版本也对应修改了。

这时执行 pnpm run build && pnpm changeset publish 发布 beta 版本:

image.png

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

pnpm changeset pre exit

这时,我们需要把变更的内容提交到远程仓库中,一方面,便于后面查看每次测试版本发布的变更记录;另一方面,changesets 默认不会到 npm 中查找当前包最新的测试包版本号并自动加1,它是根据当前仓库的测试包版本号再往上递增生成新的版本号。

发布正式版本

测试版本验证完成以后,执行以下命令把包版本修改成正式版本:

pnpm changeset version

image.png

然后我们执行以下命令发布正式版本:

pnpm changeset publish

changeset 会检查当前工作区中所有包的版本是否已经被发布过,如果没有则自动发布。

image.png

结合 GitHub Actions 实现自动化版本修改和发布

我们完成一次版本迭代,可能会包含多个功能和多个 PR,可能有多个开发者参与开发。一般会基于主分支切一个 release 分支作为开发分支。然后每个开发者基于 release 分支切一个新分支完成他的功能,Code Review 通过以后,先合并到 release 分支。整体测试完成以后,release 分支再合并到主分支,永远保证主分支的代码是足够稳定的。

下图是 alibaba/ice 仓库的 Git Graph:

image.png

自动修改版本

结合 GitHub Actions 添加 version.yml 文件到 .github/workflows 目录,当有代码合并到 release 分支时,将由 changeset 自动提交 PR 把子包的版本更新到正式版本。

name: Version

on:
  push:
    branches:
      - release-next

jobs:
  version:
    name: Version
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16]

    steps:
      - name: Checkout Branch
        uses: actions/checkout@v3

      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 7

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install

      - name: Create Release Pull Request
        uses: changesets/action@v1
        with:
          version: pnpm changeset version
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

image.png 当 release 分支所有要迭代的功能完成以后,我们可以把上面提交的版本和 Changelog 修改的 PR(分支是 changeset-release/release)合并到 release 分支上。

自动发布

当 release 分支上的代码测试通过后,将合并到 main 分支上。可结合 GitHub Actions 和 changesets publish 在 ci 流程上发布包到最新版本。

name: Release

on:
  push:
    branches:
      - main

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16]

    steps:
      - name: Checkout Branch
        uses: actions/checkout@v3

      - name: Install pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 7

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install

      - name: Build Packages
        run: pnpm run build

      - name: Publish to npm
        id: changesets
        uses: changesets/action@v1
        with:
          publish: pnpm changeset publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

最后

本文先试介绍了 monorepo 方案要解决的问题并分别介绍了 pnpm 和 changesets 工具是怎么解决的。然后基于 pnpm 和 changesets 两个工具搭建 monorepo 项目,并结合 GitHub Actions 实现自动化版本修改和发布。

欢迎大家在评论区讨论有关 Monorepo 的内容!