2023年Monorepo技术选型

PNPM 的动机,如它在官方文档介绍的所说:“Saving disk space and boosting installation speed”,节省磁盘空间和提高安装速度。除开这个动机描述的显著优点外,PNPM 内置了对 Monorepo 的支持,并解决了很多令人诟病的问题。其中,比较经典的就是 Phantom dependencies。

2023年Monorepo技术选型
PNPM

PNPM 的动机,如它在官方文档介绍的所说:“Saving disk space and boosting installation speed”,节省磁盘空间和提高安装速度。除开这个动机描述的显著优点外,PNPM 内置了对 Monorepo 的支持,并解决了很多令人诟病的问题。其中,比较经典的就是 Phantom dependencies(幻影依赖)。

一个库使用了不属于其 dependencies 里的 Package 称之为 Phantom dependencies(幻影依赖、幽灵依赖、隐式依赖),在现有 Monorepo 架构中该问题被放大(依赖提升)。

由于无法保证幻影依赖的版本正确性,给程序运行带来了不可控的风险。app 依赖了 lib-a,lib-a 依赖了 lib-x,由于依赖提升,我们可以在 app 中直接引用 lib-x,这并不可靠,我们能否引用到 lib-x,以及引用到什么版本的 lib-x 完全取决于 lib-a 的开发者。

在 npm@3 之前, node_modules 的结构是干净且可预测的,因为 node_modules 中的每个依赖项都有其自己的 node_modules 文件夹,其所有依赖项都在 package.json 中指定。

node_modules
└─ foo
   ├─ index.js
   ├─ package.json
   └─ node_modules
      └─ bar
         ├─ index.js
         └─ package.json

但是这样带来了两个很严重的问题:

  1. 依赖层级过深在 Windows 下会出现问题。
  2. 同一 Package 作为其他多个不同 Package 的依赖项时,会被拷贝很多次。

为了解决这两个问题,npm@3 重新思考了 node_modules 的结构,引入了平铺的方案。于是就出现了下面我们所熟悉的结构。

node_modules
├─ foo
|  ├─ index.js
|  └─ package.json
└─ bar
   ├─ index.js
   └─ package.json

与 npm@3 不同,pnpm 使用另外一种方式解决了 npm@2 所遇到的问题,而非平铺 node_modules。

在由 pnpm 创建的 node_modules 文件夹中,所有 Package 都与自身的依赖项分组在一起(隔离),但是依赖层级却不会过深(软链接到外面真正的地址)。

node_modules
├─ foo -> .registry.npmjs.org/foo/1.0.0/node_modules/foo
└─ .registry.npmjs.org
   ├─ foo/1.0.0/node_modules
   |  ├─ bar -> ../../bar/2.0.0/node_modules/bar
   |  └─ foo
   |     ├─ index.js
   |     └─ package.json
   └─ bar/2.0.0/node_modules
      └─ bar
         ├─ index.js
         └─ package.json

常用依赖相关命令

pnpm i

在 PNPM 中,安装依赖可以用 pnpm i 来完成。在 Monorepo 的场景下,默认情况下 pnpm i 会安装所有的依赖(包括 packages/*)。此外,pnpm i 还需要用到 3 个选项(Option):

  • --filter ,安装依赖到指定的 package,不声明要安装的依赖包则默认安装 package.json 中的所有依赖
  • --prod, P,安装依赖到 dependencies
  • --dev, D,安装依赖到 devDependencies

pnpm remove

在 PNPM 中,删除在 package.json 中的某个依赖,可以用 pnpm remove 完成。它的选项(Option)使用和 pnpm i 大同小异。其中,不同地是当我们在工作区想要删除 packages 中所有包的 package.json 中的某个依赖的时候,需要使用 -r,例如移除所有包中的 lodash

pnpm remove lodash -r

Changesets

经常维护开源项目的同学都知道的一点,每次包(Package)的发布,需要修改 package.json 的 version 字段,以及同步更新一下本次发布修改的 CHANGELOG.md。

这么一来,就会凸显一个问题,每次发布都需要手动地去更新 version、更新 CHANGELOG.md,未免有点繁琐。并且,用过 Lerna 的同学,应该都知道 Lerna 内置了对这块的支持。

但是PNPM不支持这块,所以官方文档都给大家推荐了用于支持这块能力的工具,例如 Changesets、Beachball、Auto等。

那么,这里我们要介绍的就是 Changesets。下面,我们来看一下在前面建好的 PNPM 的 Monorepo 项目中如何使用 Changesets。首先,需要执行在 Monorepo 项目的工作区下,执行如下 2 个命令:

pnpm i -wD @changesets/cli
pnpm changeset init

前者是安装 Changesets 的 CLI,后者是初始化 .changeset 文件夹以及对应的文件:

.changeset
  |-- config.json
  |__ README.md

这里,我们来看一下 config.json文件:

{
  "$schema": "https://unpkg.com/@changesets/config@1.6.4/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "linked": [],
  "access": "restricted",
  "baseBranch": "master",
  "updateInternalDependencies": "patch",
  "ignore": []
}

除开 $schema 这个不需要修改的字段, config.json 文件中列了 7 个字段,各个字段分别代表的作用为:

注意:linked、fixed、updateInternalDependencies、bumpVersionsWithWorkspaceProtocolOnly 和 ignore 选项仅适用于 monorepos 中的行为。

commit (boolean,或作为string的模块路径,或像 [modulePath: string, options: any] 这样的tuple)

此选项用于设置 changeset add 命令和 changeset version 命令是否也将使用 git 添加和提交更改的文件,以及如何为它们生成提交消息。

默认情况下,我们不提交文件,由用户提交文件。 如果为真,我们将使用默认的提交消息生成器 (["@changesets/cli/commit", { "skipCI": "version" }])。 将其设置为字符串和选项元组指定我们将从中加载提交消息生成函数的路径。 它应该是一个导出以下一项或两项的文件:

{
  getAddMessage,
  getVersionMessage
}

如果其中一种方法不存在,那么我们将不会提交为该命令更改的文件。

您将指定一个自定义提交消息生成器:

{
  "commit": ["../scripts/commit.js", { "customOption": true }]
}

这类似于变更日志生成器函数的工作方式。

access (restricted | public)

这设置了包的发布方式 - 如果访问:restricted,包将作为私有发布,需要登录到具有安装权限的 npm 帐户。 如果访问:public,则包将在公共注册表中可用。

默认情况下,npm 将范围内的 npm 包发布为restricted - 因此为确保您不会不小心公开发布代码,我们默认为restricted。 对于大多数情况,您需要将其设置为public

在指定的packages中,通过在package.json文件中设置access以此来覆盖。

如果你想阻止一个包被发布到 npm,在那个包的 package.json 中设置 private: true

baseBranch (git branch name)

changesets将与之进行比较的分支。 changesets许多内部的功能使用 git 将当前changesets与另一个分支进行比较。 这默认了将用于这些比较的分支。 这通常应该设置为您将更改合并到的主要分支。 使用此信息的命令接受 --since 选项,该选项可用于覆盖此信息。

为了让编码成为一种更具包容性的体验,我们建议将您的 master 分支的名称更改为 main。

ignore (array of packages)

此选项允许您指定一些不会发布的包,即使它们在changesets中被引用也是如此。 相反,这些changesets将被跳过,直到它们从该数组中删除。

此功能专为临时使用而设计,允许在不发布更改的情况下合并更改 - 如果您想完全停止发布包,请在其 package.json 中设置 private: true。

对此有两个警告。

  1. 如果在还包含未忽略的包的变更集中提及该包,则发布将失败。
  2. 如果包需要将其依赖项之一作为发布的一部分进行更新。

这些限制的存在是为了确保您的存储库或已发布的代码不会以损坏的状态结束。

注意:您还可以根据微匹配格式提供 glob 表达式来匹配包。

fixed (array of arrays of package names)

此选项可用于声明包应该进行版本调整并一起发布。 例如,如果你有一个 @changesets/button 组件和一个 @changesets/theme 组件,你想确保当一个组件升级到 1.1.0 时,另一个组件也升级到 1.1.0,无论它是否有 改变与否。 为此,您需要这样配置:

{
  "fixed": [["@changesets/button", "@changesets/theme"]]
}

如果你想使用这个选项,你应该阅读关于固定包的文档以完全理解实现和影响。

linked (array of arrays of package names)

此选项可用于声明包应该“共享”一个版本,而不是完全独立地进行版本控制。 例如,如果您有一个 @changesets/button 组件和一个 @changesets/theme 组件,并且您希望确保当其中一个升级到 2.0.0 时,另一个也升级到 2.0.0。 为此,您需要这样配置:

{
  "linked": [["@changesets/button", "@changesets/theme"]]
}

如果你想使用这个选项,你应该阅读链接包的文档以完全理解实现和影响。

注意:这不会像其他一些工具那样做,这是确保在发布任何包时,所有其他包也以相同的版本发布。

updateInternalDependencies

此选项设置当依赖的包发生变化时,是否应该更新它所依赖的版本。 为了使这一点更容易理解,这里有一个例子:

假设我们有两个包,一个依赖于另一个:

pkg-a @ version 1.0.0
pkg-b @ version 1.0.0
  取决于范围为 `^1.0.0 的 pkg-a

假设我们正在发布 pkg-a 和 pkg-b 的补丁——这个标志用于确定我们是否更新 pkg-b 依赖于 pkg-a 的方式。

如果选项设置为 patch,我们将更新依赖项,因此我们现在将拥有:

pkg-a @ version 1.0.1
pkg-b @ version 1.0.1
  取决于范围为 `^1.0.1 的 pkg-a

但是,如果该选项设置为minor,则它所依赖的内容只会在发生较小更改时更新,因此状态将是:

pkg-a @ version 1.0.1
pkg-b @ version 1.0.1
  取决于范围为 `^1.0.0 的 pkg-a

使用 minor 允许使用者更积极地控制他们自己的包重复数据删除,并且如果您有许多相互连接的包,他们将允许他们安装更少的版本。 使用补丁将意味着消费者将更频繁地使用更多更新的代码,但可能会导致重复数据删除问题。

如果它离开旧的 semver 范围,变更集将始终更新依赖项。

⚠ 注意:这仅适用于已在当前版本中发布的软件包。 如果 A 依赖于 B,而我们只释放 B,那么 A 就不会受到冲击。

changelog (false or a path)

此选项用于设置changelog应如何生成包的变更日志。 如果为false,则不会生成更改日志。 将其设置为字符串指定我们将从中加载变更日志生成函数的路径。 它应该是一个导出以下内容的文件:

{
  getReleaseLine,
  getDependencyReleaseLine
}

除了默认的,您还可以使用@changesets/changelog-git,它将指向提交的链接添加到更改日志中,或者使用@changesets/changelog-github,这需要 github 身份验证,并包括对添加更改集的人的感谢信息 以及相关 PR 的链接。

您将指定我们的 github 变更日志生成器:

{
  "changelog": ["@changesets/changelog-github", { "repo": "<org>/<repo>" }]
}

bumpVersionsWithWorkspaceProtocolOnly (boolean)

确定Changesets是否应该只增加使用作为工作区一部分的包的工作区协议的依赖范围。

snapshot (object or undefined)

默认值:undefined

useCalculatedVersion (optional boolean)

默认值:false

当使用changesets version --snapshot时, 默认行为是使用 0.0.0 作为快照发布的基础版本。

设置useCalculatedVersion: true 将更改默认行为并将使用基于变更集文件的计算版本。

prereleaseTemplate (optional string)

默认值: undefined

使用带占位符的模板配置快照。

可用占位符:

您可以使用以下占位符来自定义快照发布版本:

  • {tag} - 快照标签的名称,在 --snapshot something 中指定
  • {commit} - Git 提交 ID
  • {timestamp} - 发布时间的 Unix 时间戳
  • {datetime} - 发布日期和时间(14 个字符,例如 20211213000730)

注意:如果您使用带有空标签名称的 --snapshot,则不能使用 {tag} 作为占位符 - 这会导致错误。

默认行为

如果您没有指定 prereleaseTemplate,默认行为将回退到使用以下模板:{tag}-{datetime},并且在标签为空的情况下(--没有标签名称的快照),它将使用 {datetime } 仅有的。

总结就是:

  • commit 设置是否把执行 changeset add 或 changeset publish 操作时用 Git 提交修改
  • linked 设置共享版本的包,而不是独立版本的包,例如一个组件库中主题和单独的组件的关系,也就是修改 Version 的时候,共享的包需要同步一起更新版本
  • access 设置执行 npm publish 的 --access 选项,默认是restricted用于私有包的发布,通常情况下我们是公共的包,所以设置 public 即可(注意,它会被 package.json 中的 access 字段重写)
  • baseBranch 设置默认的 Git 分支,例如现在 GitHub 的默认分支应该是 main,比较老的版本应该是master
  • updateInternalDependencies 设置互相依赖的包版本更新机制,它是一个枚举(major|minor|patch),例如设置为 minor 时,只有当依赖的包新了 minor 版本或者才会对应地更新 package.json 的 dependencies 或 devDependencies 中对应依赖的版本
  • ignore 设置不需要发布的包,这些会被 Changesets 忽略

在初始化 .changeset 文件夹后,就可以正常使用 changeset 相关的命令,主要是这 3 个命令:

  • pnpm chageset 用于生成本次修改的要添加到 CHANGELOG.md 中的描述
  • pnpm changeset version 用于生成本次修改后的包的版本
  • pnpm changeset publish 用于发布包

此外,如果是在业务场景下,我们通常需要把包发到公司私有的 NPM Registry,而这有很多种配置方式。但是,需要注意的是 Changesets 只支持在每个包中声明 publishConfig.registry 或者配置 process.env.npm_config_registry,对应的代码会是这样:

//https://github.com/changesets/changesets/blob/main/packages/cli/src/commands/publish/npm-utils.ts
function getCorrectRegistry(packageJson?: PackageJSON): string {
  const registry =
    packageJson?.publishConfig?.registry ?? process.env.npm_config_registry;

  return !registry || registry === "https://registry.yarnpkg.com"
    ? "https://registry.npmjs.org"
    : registry;
}

Turborepo

说起 Turborepo,可能大家会有点陌生。但是,对于 Vercel 我想大家都知道,Turbrepo 则是 Vercel 旗下的一个开源项目。Turborepo 是用于为 JavaScript/TypeScript 的 Monorepo 提供一个极快的构建系统,简单地理解就是用 Turborepo 来执行 Monorepo 项目的中构建(或者其他)任务会非常快!

所以,你可以理解成快是选择 Turborepo 负责 Monorepo 项目多包任务执行的原因。而在 Turborepo 中执行多包任务是通过 turbo run <script>。不过,turbo runlerna run 直接使用有所不同,它需要配置 turbo.json 文件,注册每个需要执行的 script 命令。

在 Turborepo 中有个 Pipelines的概念,它是由 turbo.json 文件中的 pipline 字段的配置描述,它会在执行 turbo run 命令的时候,根据对应的配置进行有序的执行和缓存输出的文件。

举个例子,通常情况下我们一个 Monorepo 项目中的每个包可能会有 devbuildtestclean 等 4 个命令,那么对应的 turbo.json 的配置会是这样:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "clean": {
      "dependsOn": ["^clean"]
    },
    "test": {
      "dependsOn": ["build","lint"]
    },
    "dev": {
      "cache": false
    }
  }
}

可以看到,pipeline 中的每个 key 则对应着每个需要执行的 turbo run 命令的名称,其中 dependsOnoutputscache 等 3 个字段分别作用为:

  • dependsOn 表示当前命令所依赖的命令,^ 表示 dependencies 和 devDependencies 的所有依赖都执行完 build,才执行 build
  • outputs 表示命令执行输出的文件缓存目录,例如我们常见的 dist、coverage 等
  • cache 表示是否缓存,通常我们执行 dev 命令的时候会结合 watch 模式,所以这种情况下关闭掉缓存比较切合实际需求

这样一来,我们就可以使用诸如 turbo run build test 的命令,它则会按 pipeline 的配置依次执行对应的命令。

当然,如果你想每个命令都支持单独执行,可以直接配置为 {} 即可。此外,如果要使用 turbo run 命令,还需要在 package.json 中声明 packageManage 字段为指定的包管理工具及版本,例如 "packageManager": "pnpm@8.1.1"