1.前言
通常,我们会根据自身业务的实际情况,将通用的组件、逻辑等提取成NPM包,方便以后复用。但这些提取出来的NPM包可能互相之间存在依赖,如果仍然采用 Multirepo 的形式进行管理,则在包的版本管理、依赖管理、调试等诸多方面存在不便。
Monorepo 能很好的解决上述问题,为更加方便的使用 Monorepo 来管理我们的项目,我们需要一些趁手的工具,Lerna + Yarn Workspace 的组合就是这样一件优秀的工具。然而 Lerna 中途经历过较大的改动,其文档也不是那么容易看明白,中文材料大多使用较老版本的 Lerna,使用上和较新版本存在差异。
本文将介绍较新版本的 Lerna 如何结合 Yarn Workspace 管理 Monorepo 项目,后续操作在 Lerna V7 和 V8 中经过实践。
2.Lerna + Yarn Workspace
其实除了 Lerna + Yarn Workspace 的方案外,还有其他方案也可以帮助我们管理 Monorepo 项目,比如 pnpm 等,但我选择 Lerna + Yarn Workspace 一方面是由于习惯了使用 yarn,只要引入 Lerna 即可,另一方面公司的基建对 yarn 也有很好的支持,所以选择了 Lerna + Yarn Workspace。
在使用 Lerna + Yarn Workspace 时,两者的分工有所不同:
- Yarn 负责依赖管理,在 install 时将子包相同的依赖安装到根目录下的 node_modules 中,避免了在子包中重复安装。同时 Yarn 也可以处理子包之间的相互依赖关系,使用软链的方式连接各个子包。
- Lerna 则负责子包的发布,包括版本号更新、依赖更新、发布等。
3.入门实践
3.1.初始化
3.1.1.项目初始化
首先你需要初始化 Monorepo 项目,新建一个目录 my-monorepo-project,进入到目录中执行:
yarn init
这一步完成后,项目根目录下会出现 package.json,这里需要将 private 字段设为 true。
3.1.2.Workspaces 初始化
接下来进行 Workspaces 初始化,在根目录新建 packages 文件夹,并在 packages.json 中新增 workspaces 配置:
"workspaces": [
"packages/*"
]
workspaces 字段定义了一个包含多个路径的数组,这些路径指向了项目中的各个子包的位置。如上配置就是将 packages 目录下的所有项目都看做是子包。
这一步完成之后,就可以在根目录下执行 yarn 安装依赖了,假设我们的项目目录如下:
/my-monorepo-project
|-- packages
| |-- package1
| |-- package2
| |-- package3
|-- package.json
|-- yarn.lock
其中 package1 依赖了 package2,则 yarn 之后,my-monorepo-project 的根目录下会出现 node_modules 文件夹,在 node_modules 查看各个子包对应的目录,会发现它们是以软链的形式链接到各自的源码目录中。
值得注意的是,此时就算 package1 的 package.json 中没有声明对 package2 的依赖,但也可以直接在 package1 中引用 package2 了。但当我们发布后,由于 package1 没有声明对 package2 的依赖,用户在安装 package1 时不会同时安装 package2,就会造成运行异常。正确的做法仍然需要在 package1 的 package.json 中声明对 package2 的依赖,不过不要通过 yarn workspace package1 add package2
的方式添加依赖,这样 package2 会从 npm 上下载,而不是链接到本地源码。
3.1.3.Lerna初始化
现在就可以引入 Lerna 了,在根目录中继续执行:
yarn add lerna -D
npx lerna init
如此一来,在根目录下就会新增 lerna.json 文件,其内容是:
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "independent",
"npmClient": "yarn"
}
在较早版本的 Lerna 生成的 lerna.json 文件中,还需要设置 useWorkspaces 字段,不过Lerna 7 和 8 中已经不用设置该字段了。
另外需要注意 version 字段的值。version 可以设置为具体的版本号,比如 0.0.1,此时 Lerna 将采用固定模式(Fixed/Locked mode)管理子包版本,即有的子包将共享同一个版本号。Lerna 默认采用该方式,version 初始值为 0.0.0。
也可以将 version 设置为 independent,表示每个子包都可以独立地管理自己的版本号(Independent mode),当发布时 Lerna 会让你为子包选择版本号。由于我是将已有的项目改造为 Monorepo 项目,各子包已经独立发版很长时间了,所以将 version 改成了 independent。
lerna.json 中可能需要手动添加 includeMergedTags 配置,以设置在执行 lerna changed 时包含合并到当前分支的标签,相关内容在第4节。
3.2.开发
完成初始化后,我们就可以开发我们的子包了。在开发过程中,我们可能需要添加第三方依赖,也有可能需要运行子包的 scripts 脚本。我们来看看如何执行这些常见的操作。
3.2.1.添加依赖
如果需要添加第三方依赖,可以将依赖添加到 workspaces 根目录:
# 在任何目录执行
yarn add <依赖名> -W
# 或者在根目录中执行
yarn add <依赖名>
这样依赖会被安装到根目录的 node_modules 中,所有子包都可以引用。
如果需要为某个子包单独添加某个依赖,可以执行:
# 在任何目录执行
yarn workspace <子包名> add <依赖名>
# 或者在进入子包对应的目录中执行
yarn add <依赖名>
这样依赖只会被会被安装到子包对应目录下的 node_modules 中,其他子包不能直接引用。注意子包名
指的是子包 package.json 中 name 字段的值,不是子包的目录名。
我们可以通过如下命令查看子包间的依赖关系,防止日后理不清子包的依赖关系:
yarn workspaces info
3.2.2.运行script
如果需要执行某个子包中的脚本,可以执行:
yarn workspace <子包名> run build
这样就会运行子包的 build 命令。
3.3.检查修改
如果需要检查自上次发布以来在 Git 中已经提交但尚未发布的修改,执行:
npx lerna changed
该命令的结果形如:
# 没有未发布的修改
lerna notice cli v7.4.2
lerna info versioning independent
lerna notice Current HEAD is already released, skipping change detection.
lerna info No changed packages found
# 有未发布的修改
lerna notice cli v7.4.2
lerna info versioning independent
lerna info Looking for changed packages since package1@0.0.1
package1
package2
该命令的结果不仅包含发生修改的子包本身,还包括依赖了该子包的其他子包。比如 package2 发生修改,则依赖它的 package1 也会被识别为需要发布,因为需要修改 package1 依赖的 package2 的版本。
3.4.发布
之前说过,在 lerna.json 中配置 version 为 independent 表示每个子包可以独立地更新版本号。假设我们修改了 package1 的代码,首先需要提交修改,否则发布时会因为没有提交记录而被 Lerna 卡住。
提交本地修改后,执行:
npx lerna publish
这一步按顺序会执行如下动作:
- Lerna 查找自上次发布以来的修改,同 lerna changed。
- Lerna 向开发者确认下一个版本号。
- 更新变更包的版本号,这一步 Lerna 也会更新依赖变更包的其他子包,并更新依赖的版本号。
- 创建新的 commit 和 tag,并推送到远端。
- 将更新后的包发布到 npm。
如果需要将包发布到公司的 npm 源上,需要在根目录新增 .npmrc 文件:
registry=公司npm源地址
如果上次发布了测试版本(比如 beta 版本),经过测试没有问题,需要将版本号修改为正式版本,但又没有修改代码,可以参考以下流程:
- 执行
npx lerna version --no-private --force-publish=package-2,package-4
。这将让你选择 package-2 和 package-4 的新版本号、以及依赖 package-2 或 package-4 的包的新版本号。lerna 将修改 package.json 中的版本号,并打 tag。 - 执行
npx lerna publish from-package
,from-package 基于本地的 package.json 中的版本号与 npm 上的版本进行对比,以决定哪些包需要发布。
4.踩坑
4.1.已有项目改造
我是将已经独立发版一段时间的好几个子包改造为使用 Monorepo 架构进行管理。由于 Lerna 基于 Tag 判断有哪些子包需要发布,由于之前发布子包时没有打Tag,所以执行 npx lerna changed
会提示 lerna success found 9 packages ready to publish
,意思是所有的子包都要发布。但执行 npx lerna publish from-package
时由于本地包和 npm 上的版本相同,会提示 No unpublished release found
,意思是没有需要发布的包。
为了解决上述问题,需要补一下Tag,具体方法是执行 npx lerna version --no-private
,Lerna 会让填写每个子包的新版本号,没有修改的子包版本号可以保持不变。执行完毕后再执行 npx lerna changed
会提示 Current HEAD is already released, skipping change detection... No changed packages found
,即达成目的。
随后执行 npx lerna publish from-package
,发布需要更新的子包即可。如果没有要发布的子包,这一步也可以不执行。
4.2.lerna changed 不符合预期
我们的开发流程一般是从 master 分支拉取开发分支,在开发分支中进行开发和发布,然后再提一个 PR 将开发分支合并到 master。但我们发现在开发分支发布之后执行 npx lerna changed
提示 No changed packages found,这是符合预期的。但合并到 master 后,在 master 分支执行 npx lerna changed
则会提示所有的子包都要更新(或者需要更新的子包范围不符合预期)。
lerna changed 命令是通过比较当前工作树与最近的标签之间的差异来识别哪些包有改动的。上述问题的原因可能是:
- lerna.json 中没有配置 includeMergedTags;
- 有些 tag 没有被推送到远端;
includeMergedTags 的配置如下:
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "independent",
"npmClient": "yarn",
"command": {
"publish": {
"includeMergedTags": true
}
}
}
includeMergedTags 设置为 true 表示在使用 lerna changed 或 lerna diff 命令时,会包含那些在当前提交之后被合并进来的标签。这样一来,在 master 分支执行 lerna changed 命令时,就会包含从其他分支合并进来的标签。关于includeMergedTags的更多信息参考 官网。
正常情况下 lerna version 命令会自动生成版本标签并推送到远端,但有时候新标签没有被推送到远端(比如某些子包已经有相同的 tag,导致 lerna version 执行异常),或者是手动打的标签,这时如果使用 git push 默认情况下并不会将本地的标签推送到远端仓库,进而导致其他开发者执行 lerna changed 命令时,参与比较的 tag 不是最新的,从而产生不符合预期的输出。尝试使用 git push --tags
将本地 tag 推送到远端,这也提示我们在发布之后,最好去远端仓库中检查 tag 是否更新。