基于 lerna 和 yarn workspace 的 monorepo 工作流
由于 yarn 和 lerna 在功能上有较多的重叠,我们采用 yarn 官方推荐的做法,用 yarn 来处理依赖问题,用 lerna 来处理发布问题。能用 yarn 做的就用 yarn 做
可以避免管理很多仓库
使用 monorepo 的原因
我正在开发的项目 A,依赖了已经线上发布的项目 B,但是随着项目 A 的不断开发,又需要不时修改项目 B 的代码(这些修改暂时不必发布线上),如何能够在修改项目 B 代码后及时将改动后在项目 A 中同步? 在项目 A 发布上线后,如何以一种优雅的方式解决项目 A,B 版本升级后的版本同步问题?
为了解决
多个库之间共享依赖
库与库之间相互依赖的问题
测试、构建、lint、commit、发布等脚本共享
手动改版本号的问题
monorepo 最主要的好处是统一的工作流和Code Sharing。比如我想看一个 pacakge 的代码、了解某段逻辑,不需要找它的 repo,直接就在当前 repo;当某个需求要修改多个 pacakge 时,不需要分别到各自的 repo 进行修改、测试、发版或者 npm link,直接在当前 repo 修改,统一测试、统一发版。
monorepo 方案的优势
避免重复安装包,因此减少了磁盘空间的占用,并降低了构建时间;
内部代码可以彼此相互引用;
代码重用将变得非常容易
依赖管理将变得非常简单:同理,由于项目之间的引用路径内化在同一个仓库之中,我们很容易追踪当某个项目的代码修改后,会影响到其他哪些项目。通过使用一些工具,我们将很容易地做到版本依赖管理和版本号自动升级;lerna
monorepo 方案的劣势
项目粒度的权限管理变得非常复杂:这意味着 A 部门的 a 项目若是不想被 B 部门的开发者看到就很难了。
新员工的学习成本变高:不同于一个项目一个代码仓库这种模式下,组织新人只要熟悉特定代码仓库下的代码逻辑,在 monorepo 策略下,新人可能不得不花更多精力来理清各个代码仓库之间的相互逻辑,当然这个成本可以通过新人文档的方式来解决,但维护文档的新鲜又需要消耗额外的人力;
当多个子项目放在一个代码仓库,并且子项目之间又相互依赖时,我们面临的棘手问题有两个:
如果我们需要在多个子目录执行相同的命令,我们需要手动进入各个目录,并执行命令; yarn worksapces
当一个子项目更新后,我们只能手动追踪依赖该项目的其他子项目,并升级其版本。 lerna
依赖管理
monorepo: 各个库之间存在依赖,如 A 依赖于 B,因此我们通常需要将 B link 到 A 的 node_module 里,一旦仓库很多的话,手动的管理这些 link 操作负担很大,因此需要自动化的 link 操作,按照拓扑排序将各个依赖进行 link
解决方式:
通过使用 workspace,yarn install 会自动的帮忙解决安装和 link 问题(https://github.com/lerna/lerna/issues/1308)
$ yarn install # 等价于 lerna bootstrap --npm-client yarn --use-workspaces
在某一个 workspace 下执行 yarn install 和在 root 下执行 yarn install 的效果是一样的
依赖清理
在依赖乱掉或者工程混乱的情况下,清理依赖
普通项目:直接删掉 node_modules 和编译之后的产物即可
monorepo: 不仅需要删除 root 的 node_modules 的编译产物还需要删除各个 package 里的 node_modules 以及编译产物
解决方式:使用 lerna clean 来删除所有的 node_modules,使用 yarn workspaces run clean 来执行所有 package 的清理工作
$ lerna clean # 清理所有的node_modules
$ yarn workspaces run clean # 执行所有package的clean操作
安装/删除依赖
monorepo: 一般分为三种场景
给某个 package 安装依赖:yarn workspace packageB add packageA 将 packageA 作为 packageB 的依赖进行安装
给所有的 package 安装依赖: 使用 yarn workspaces add lodash 给所有的 package 安装依赖
给 root 安装依赖:一般的公用的开发工具都是安装在 root 里,如 typescript,我们使用 yarn add -W -D typescript 来给 root 安装依赖
对应的三种场景删除依赖如下
yarn workspace packageB remove packageA
yarn workspaces remove lodash
yarn remove -W -D typescript
项目构建
root 目录下的 package.json 中编写脚本
"build": "yarn workspaces run build",
yarn workspaces run xxx
执行每一个 package 下的 xxx 命令
该脚本会执行每一个子目录下的 build 脚本
会按照 root 目录下的 package.json 中
"workspaces": [
"packages/*"
],
的顺序执行各个子项目的 build 脚本(通配符时就按照在项目中的次序)
版本升级及发包
发布 npm 包
发布完 git 后我们还需要将更新的版本发布到 npm 上,以便外部用户使用
我们发现手动的执行这些操作是很麻烦的且及其容易出错,幸运的是 lerna 可以帮助我们解决这些问题
lerna 提供了 publish 和 version 来支持版本的升级和发布
publish 的功能可以即包含 version 的工作,也可以单纯的只做发布操作。
lerna version
lerna version 的作用是进行 version bump,支持手动和自动两种模式
只发布某个 package
不支持,lerna 官方不支持仅发布某个 package,见 https://github.com/lerna/lerna/issues/1691,如果需要,只能自己手动的进入package进行发布,这样lerna自带的各种功能就需要手动完成且可能和lerna的功能相互冲突
由于 lerna 会自动的监测 git 提交记录里是否包含指定 package 的文件修改记录,来确定版本更新,这要求设置好合理的 ignore 规则(否则会造成频繁的,无意义的某个版本更新),好处是其可以自动的帮助 package 之间更新版本
例如如果 ui-form 依赖了 ui-button,如果 ui-button 发生了版本变动,会自动的将 ui-form 的对 ui-button 版本依赖更新为 ui-button 的最新版本。 如果 ui-form 发生了版本变动,对 ui-button 并不会造成影响。
lerna publish
package的源码需要打包后进行发布,需要在lerna publish之前,手动打包已经改动的package,有时候会在publish之后才想起来还没打包,针对这个问题,把build流程和publish发布封装一个npm scripts,打包后自动publish
npm publish之前需要进行build且需要在打包后的目录下运行npm publish
lerna publish from-package
在注册表中不存在版本的最新提交中发布包 ( from-package)。
除了要发布的包列表是通过检查每个 package.json 包并确定注册表中是否不存在任何包版本来确定的。注册表中不存在的任何版本都将被发布。当以前 lerna publish 未能将所有包发布到注册表时,这很有用。
发布 package 中 pkg.json 上的 version 在 registry(高于 latest version)不存在的包
lerna publish 永远不会发布 package.json 中 private 设置为 true 的包
在 lerna 中,如果 workspaces 之前存在依赖的话,在这次发包中,例如 A 这个包依赖了 B,B 在这次发包中版本升级了,那么这里 A 里面依赖的 B 也要更新到对应的版本。
lerna publish 一般有以下几种形式
发布自上次发布来有更新的包(这里的上次发布也是基于上次执行lerna publish 而言)
发布在最近 commit 中修改了 package.json 中的 version (且该 version 在 registry 中没有发布过)的包(即 lerna publish from-package)
发布
创建项目
初始化项目 & 初始化 lerna
yarn init
npx lerna init
lerna 配置使用 yarn workspaces, 使用 independent 模式
lerna.json
{
"packages": ["packages/*"],
"version": "independent",
"npmClient": "yarn",
"useWorkspaces": true
}
npmClient: 我们显示声明了我们的包客户端(npmClient)为 yarn,并且让 Lerna 追踪我们 workspaces 设置的目录,这样我们就依旧保留了之前 workspaces 的所有特性(子项目引用和通用包提升)。
version: 除此之外一个有趣的改动在于我们将 version 属性指定为一个关键字 independent,这将告诉 lerna 应该将每个子项目的版本号看作是相互独立的。当某个子项目代码更新后,运行 lerna publish 时,Lerna 将监听到代码变化的子项目并以交互式 CLI 方式让开发者决定需要升级的版本号,关联的子项目版本号不会自动升级,反之,当我们填入固定的版本号时,则任一子项目的代码变动,都会导致所有子项目的版本号基于当前指定的版本号升级。
独立模式,每个 package 都可以有自己的版本号。版本号维护在各自 package.json 的 version 中。每次发布前都会提示已经更改的包,以及建议的版本号或者自定义版本号。
lerna publish:发布代码有变动的 package,因此首先您需要在使用 Lerna 前使用 git commit 命令提交代码,好让 Lerna 有一个 baseline;
lerna publish 参数
--npm-tag [tagname]
— 使用给定的 npm dist-tag (默认为 latest)发布到 npm。
配置 package.json 使用 yarn workspacess
// package.json
{
"name": "monorepo-template",
"private": true, // root禁止发布
"workspaces": [ // 配置package目录
"packages/*"
]
}
创建 package
注意 package.json 模块的配置
// package.json
{
"name": "ui",
"version": "1.0.0",
"main": "index.js",
"publishConfig": {
"access": "publish" // 如果该模块需要发布,对于scope模块,需要设置为publish,否则需要权限验证
}
}
- 创建一个 lib 模块并初始化
cd packages && mkdir lib && cd lib && yarn init -y
- 创建一个 ui 模块并初始化 --- 需要发布
使用 lerna create 快速创建 package --- 推荐
npx lerna create ui -y
- 创建一个 app 模块
npx lerna create app -y
将 lib 作为 app 的依赖
yarn workspace app add lib@1.0.0 # 这里必须加上版本号,否则报错
一般 root 只包含一些开发工具依赖如 webpack,babel,typescript 等
yarn add -W -D typescript
添加 conventional-commit 支持
lerna 的 version_bump 和 changelog 生成都依赖于 conventional-commit,因此需要保证 commit-msg 符合规范。
添加@commitlint/cli 和@commitlint/config-conventional 以及 husky
yarn add -W -D @commitlint/config-conventional @commitlint/cli lint-staged husky
commitlint.config.js
module.exports = {
extends: ["@commitlint/config-conventional"]
};
package.json 中配置 commit-msg 的 hooks
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
添加 git-cz,支持 commit-msg 提示
$ yarn add -W -D commitizen cz-conventional-changelog
配置 commitizen 并添加 commit 为 npm script
// package.json
"scripts": {
"commit": "git-cz"
},
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
这样后续 commit,就可以使用 yarn commit 进行 commit,其会自动做出如下提示
发版
package.json 中新增发版脚本
"release": "lerna version --no-git-tag-version",
"publish": "lerna publish from-package",
"publish:next": "lerna publish from-package --dist-tag next"
lerna 常用命令
本项目使用 lerna 来管理多个插件
lerna 常用命令
$ npx lerna init # 初始化
$ npx lerna create @templatejs/parser # 创建一个package
$ npx lerna add yargs --scope=@templatejs/parser # 给package安装依赖
$ npx lerna list # 列出所有的包
$ npx lerna bootstrap # 安装全部依赖
$ npx lerna link # 建立全部软连接
$ npx lerna changed # 列出下次发版lerna publish 要更新的包
$ npx lerna publish # 会打tag,上传git,上传npm
发布步骤
$ yarn release
# git commit
$ yarn publish:next # 测试包
$ yarn publish # 正式包
<del>也可使用下面的命令发布</del>
不打 tag 发布
$ yarn test
$ yarn build
$ npx lerna version --no-git-tag-version # 仅修改version
$ npx lerna publish from-package --dist-tag next # 发布测试包,需要选择对应的 alpha 版本号
$ npx lerna publish from-package # 发布正式包
打 tag 发布
$ yarn test
$ yarn build
$ npx lerna publish --dist-tag next # 发布测试包,需要选择对应的 alpha 版本号
$ npx lerna publish # 发布正式包
如何从 multirepo 迁移至使用 monorepo 策略?
至此,我们学会了如何采用 monorepo 策略组织项目代码的最佳实践,或许您已经开始跃跃欲试想要尝试前文提到的种种技巧。从 0 搭建一个 monorepo 项目,当然没问题!可是如果要基于已有的项目,将其转化为一个使用 monorepo 策略的项目呢?
或许您注意到了,Lerna 为我们提供了 lerna import 命令,用来将我们已有的包导入到 monorepo 仓库,并且还会保留该仓库的所有 commit 信息。然而实际上,该命令仅支持导入本地项目,并且不支持导入项目的分支和标签 。
推荐阅读
注意事项
运行
lerna publish
命令时需要提交代码,也就是执行 commit & pushpackage.json 中的
// 发包的地址
"publishConfig": {
"registry": "xxx.xxx.com"
},
push 执行
lerna publish
通过命令行交互的形式选择每一个 package 的版本,然后为你自动修改每个包的 package.json,对于依赖的 package 也会自动更新到对应的版本,并且提交到 gitnpx lerna publish from-package
会检查每一个包的 package.json(即使该包没有改动),按 package.json 中的版本进行发包,只有在当前版本不存在于注册表中的时候才可以发布成功
lerna version 会做的事情
1.识别从上次打标记发布以来发生变更的 package
2.版本提示
3.修改 package 的元数据反映新的版本,在根目录和每个 package 中适当运行lifecycle scripts
4.在 git 上提交改变并对该次提交打标记(git commit & git tag)
5.提交到远程仓库(git push)
lerna version --no-git-tag-version 不会执行4 、5
lerna publish / lerna version 是否需要build?
需要!但是不需要切换到打包后的目录,lerna自动帮我们在打包后的目录运行publish
会更新所有包 还是只更新修改过的包?
是的会更新所有包的版本,不论你是否修改
publish结束之后需要我们自己commit & push来将版本号提交到远端
lerna version会将版本修改commit&push
直接调用lerna publish会先进行lerna version的操作