转载请保留这部分内容,注明出处。
关注公众号“头号前端”,每周新鲜前端好文推送。另外,头条号前端团队非常 期待你的加入
前言
将大型代码仓库分割成多个独立版本化的软件包(package)对于代码复用来说非常有用。 虽然拆分子仓库、拆分子 NPM 包(For web)是进行项目隔离的天然方案,但当仓库内容出现关联时,没有任何一种调试方式比源码放在一起更高效。 工程化的最终目的是让业务开发可以 100% 聚焦在业务逻辑上 ,那么这不仅仅是脚手架、框架需要从自动化、设计上解决的问题,这涉及到仓库管理的设计。 一个理想的开发环境可以抽象成这样: “只关心业务代码,可以直接跨业务复用而不关心复用方式,调试时所有代码都在源码中。” 在前端开发环境中,多 Git Repo,多 Npm 则是这个理想的阻力,它们导致复用要关心版本号,调试需要 Npm Link。 当维护的 package 越来越多时,就会遇到以下几个问题:
-
管理困难: package之间相互依赖,开发人员需要在本地手动执行
npm link,维护版本号的更替,独立仓库间组件版本号的维护需要手动操作,因为源代码不在一起,所以没有办法整体分析依赖,自动化管理版本号的依赖。 -
调试困难: issue难以统一追踪,管理,因为其分散在独立的repo里。
-
占用总空间大: 每一个package都包含独立的
node_modules,而且大部分都包含babel,webpack等开发时依赖,安装耗时冗余并且占用过多空间,比如 React 等大型模块,时间久了可能会占用几十 GB 的额外空间,对于没有外接硬盘的同学来说,定期清理不用的项目下的node_modules也是一件麻烦事。
尤其是在开发 UI 组件库,Util 工具库以及插件库等场景
Monorepo
Monorepo is a unified source code repository used by an organisation to host as much of its code as possible.
Monorepo 它是一种管理组织代码的方式,在这种方式下会摒弃原先一个 module 一个 repo 的方式,取而代之的是把所有的 modules 都放在一个 repo 内来管理。可以看看这里 Why is Babel a monorepo?
Monorepo 简单的说,是指将公司的所有代码放到一个 Git / Mercurial / Subversion 的代码仓库中。然后经过合理的划分,分为多个package 的模块。比如一个普通的互联网公司,代码仓库的架构可以这样子:
./frontend/web/vendors
./frontend/web/vendors/vue.js
./frontend/web/ui/registration
./frontend/web/ui/login
./frontend/ios/apps/app1
./frontend/ios/vendors/yoga
./frontend/ios/features/authentication
./frontend/ios/libraries/network
./frontend/android/apps/app1
./frontend/android/vendors/sqldelight
./shared/protobuf_schema
./backend/vendors/envoy_proxy
./backend/messaging/gateway
....
这样子分割,整体上是很大的,但是每个人关注点是分散的,每次修改的应该是不同的几个文件夹内, 比如要修改前端和后端的通信协议,只需要一个 commit 就可以把 protobuf 改掉,同时把使用 protobuf 的后端和三个前端的代码一起改了。
Lerna 它是基于 Monorepo 理念在工具端的最流行的实现之一。
Lerna is a tool that optimizes the workflow around managing multi-package repositories with git and npm. 引用官方的说法:是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目。
Lerna 的基础使用
推荐全局安装,因为会经常用到 lerna 命令
npm i -g lerna
1. 初始化:
$ mkdir lerna-repo && cd $_
$ lerna init
2. lerna init 创建项目
-
--independent(独立模式):每个包都有自己独立的版本号。lerna会配合git,检查文件变动,只发布有改动的package。
-
--fixed(固定模式):默认。所有package 共用一个版本号,比如:babel 任何 package 的 major change 均会导致所有包都会进行 major version的更新。
可以看到以下文件
➜ lerna-repo git:(master) ✗ ls
lerna.json package.json packages
// package.json
{
"name": "root",
"private": true, // 表示私有的,不会被发布,是管理整个项目,与要发布到npm的解耦
"devDependencies": {
"lerna": "^3.22.1"
}
}
// lerna.json
{
"packages": [
"packages/*"
],
"version": "0.0.0" // lerna init -i 创建这个参数如后面所示"version": "independent"
}
3. Lerna create 创建管理包
使用 lerna create@demo/core 按照命令行提示就能创建一个包
➜ lerna-repo git:(master) ✗ lerna create @demo/core
lerna notice cli v3.22.1
lerna WARN ENOREMOTE No git remote found, skipping repository property
package name: (@demo/core)
version: (0.0.0)
description:
keywords:
homepage:
license: (ISC) MIT
entry point: (lib/core.js)
git repository:
About to write to /Users/username/lerna-repo/packages/core/package.json:
{
"name": "@demo/core",
"version": "0.0.0",
"description": "> TODO: description",
"author": "sundaojiao <[sundaojiao@bytedance.com](mailto:sundaojiao@bytedance.com)>",
"homepage": "",
"license": "MIT",
"main": "lib/core.js",
"directories": {
"lib": "lib",
"test": "__tests__"
},
"files": [
"lib"
],
"publishConfig": {
"registry": "[http://bnpm.byted.org](http://bnpm.byted.org/)"
},
"scripts": {
"test": "echo \"Error: run tests from root\" && exit 1"
}
}
Is this OK? (yes) y
lerna success create New package @demo/core created at ./packages/core
同理创建了 lerna create@demo/plugin
可以获取到如下结构:
4. Lerna add 添加依赖
lerna add eslint // 为所有 package 增加 eslint 模块
lerna add react --scope @demo/core // 为 @demo/core 增加 react 模块
lerna add @demo/core --scope @demo/plugin // 增加内部模块之间的依赖,把 @demo/core 添加到 @demo/plugin 的依赖中
结果如下
5. lerna bootstrap :安装所有依赖项并链接任何交叉依赖项
为所有包安装依赖,一般从远程 monorepo 仓库克隆代码之后就要经过这步
-
--hoist这个选项,会把共同依赖的库安装到根目录的node_modules下, 统一版本
-
--npm-client指定安装用的npm client,如下
lerna bootstrap --npm-client=yarn
这些常用的参数都可以在 lerna.json中进行配置
{
"packages": [
"packages/*"
],
"command": {
"bootstrap": {
"ignore": "component-*", // 忽略部分目录,不进行安装依赖
"npmClient": "yarn",
"hoist": true
}
},
"version": "0.0.0"
}
6. lerna clean 删除各个包下的node_modules
7. Lerna version
具体识别出修改的包 --> 创建新的版本号 --> 修改package.json --> 提交修改 打上版本的tag --> 推送到git上。
- --conventional-commits
使用了这个选项, lerna会收集日志, 自动生成 CHANGELOG
- --changelog-preset
修改changelog生成插件, 默认是 angular
- --exact 将会锁定包之间的依赖关系,而不是使用 semver (^)向后兼容的形式
8. lerna publish 发布版本
主要进行一下步骤
-
检查从上一个
git tag之后是否有提交,没有提交就会显示No changed packages to publish的信息,然后退出 -
检查依赖了修改过的包的包,并更新依赖信息
-
提交相应版本的
git tag,这个需要在命令行中确认 -
发布修改的包及依赖它们的包
一些参数配置
-
--npm-tag 为发布的版本添加 dist-tag,在发非 master 分支时经常使用的,发布 prerelease 版本
-
from-git 直接发布当前的 commit,用于发布失败的时候,已经自动生成 tag 的
和 yarn workspace 结合
当然,如果对于 lerna bootstrap,add 觉得有成本的,可以使用 yarn workspace,对于从远程仓库克隆的包,直接运行 yarn 即可(
yarn install # 等价于 lerna bootstrap --npm-client yarn --use-workspaces --hoist
),就能自动建立链接,npm 5 以前,安装速度差异较大,所以选用 yarn。
优势
-
不用设置 hoist,所有的项目依赖将被安装在一起,这样可以让 Yarn 来更好地优化它们
-
Yarn 自动将使用一个单一的 lock 文件,而不是每个包都有一个,这意味着拥有更少的冲突和更容易的进行代码检查。
开启 yarn workspace,需要配置根目录下的 package.json 以及 lerna.json
// package.json
{
"private": true,
"workspaces": ["packages/*"]
}
// lerna.json
{
"useWorkspaces": true,
"npmClient": "yarn",
}
然后添加依赖等就可以用yanr workspace 的相关命令进行了
- 将@demo/core作为@demo/plugin的依赖
yarn workspace @demo/plugin add @demo/core/1.0.0 # 这里必须加上版本号,否则报错
- 仅安装在根目录
yarn add -W -D typescript jest
其他一些常用命令
// workspace 不受 Yarn Workspace 管理,只需在此 workspace 目录下添加 .yarnrc 文件
yarn config set workspaces-experimental true
// 指定 workspace 执行 command
yarn workspace <workspace_name> <command>
// 每个 workspace 下执行 <command>
yarn workspaces <command>
// 显示当前各 workspace 之间的依赖关系树
yarn workspaces info [--json]
Yarn 2.X 版本对于 workspace 又有了较大的提升
Lerna 实践
在具体实践中,得保证有完善的工作流,风格统一的结构,自动的更新日志
1. 风格统一的 commit 约束
lerna 的 version_bump 和 changelog 生成都依赖于 conventional-commit,因此需要保证 commit-msg 符合规范。 使用 commitizen 和 cz-lerna-changelog 来规范提交
yarn add -W -D commitizen cz-lerna-changelog @commitlint/cli @commitlint/config-conventional husky standard lint-staged
安装完成后,在根 package.json 中增加 config 字段,把 cz-lerna-changelog 配置给 commitizen
{
"name": "root",
"private": true,
"scripts": {
"gc": "git-cz" // 命令行工具
},
"config": {
"commitizen": {
"path": "./node_modules/cz-lerna-changelog"
}
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"*.js": [
"standard --fix",
"git add"
]
},
}
同时在根目录下面创建 commitlint.config.js
// 在工程根目录为 commitlint 增加配置文件 commitlint.config.js 为commitlint 指定相应的规范
// commmitlint.config.js
module.exports = {
extends: [
"@commitlint/config-conventional"
]
};
2. 自动更新的 changelog
在根目录可以使用convention-commit 来发版,利用了上面 lerna version 的命令行
// package.json
{
"scripts":
{
version: "lerna version --conventional-commits" ## 生成changelog文件以及根据commit来进行版本变动
}
}
不过一般直接运行 publish,可以在 lerna.json 进行如下配置
"command": {
"version": {
"conventionalCommits": true
},
"publish": {
"conventionalCommits": true, // 生成changelog文件
"exact": true // 准确的依赖项
}
},
"ignoreChanges": [
"**/*.md"
],
Lerna 的使用痛点
由于源码在一起,仓库变更非常常见,存储空间也变得很大,甚至几 GB,CI 测试运行时间也会变长。即便如此,团队中任何人都不想回到 git submodules 多仓库的方式。
Lerna 使用中遇到的一些问题
1. 发布失败之后解决
-
在你这次失败之后使用
from-git参数,即lerna publish from-git -
可以手动回退 git 到 release 之前的版本,并删除相应的
git tag,如下:
git reset --hard HEAD~1 && git tag -d $(git log --date-order --tags --simplify-by-decoration --pretty=format:'%d' | head -1 | tr -d '()' | sed 's/,* tag://g')
2. 依赖包之间锁定版本的问题
- 建议导入包的时候不是包含的,统一有业务方全部引入避免这个问题
总结
monorepo 感觉更适合内聚性比较强的工具类、框架类工程,例如react相关、 Angular相关,一般发布会有一系列的依赖同步更新;同时这种工程发展是可控的,不会随意膨胀,所以库的体积也是可控的。 业务类的工程代码管理感觉不适合用 monorepo, 一是业务代码膨胀很快,不可控,体积会变很大,迟早要拆; 二是用了 monorepo 自动化发布不好搞,业务一般是要求能独立发布,互不影响,多工程好搞自动化,只要关心repo地址就行,剩下的事情外部工具直接执行repo内的ci文件就行,monorepo 的话是不是要写很多文件夹的逻辑; 三是业务类的工程往往和组织架构相关,不同的一群人各做各的业务,monorepo 不好做细粒度的权限管理,目前 github gitlab 的权限管理只能到仓库级还不能到目录级;