monorepo项目的一次尝试 - Sara2009/start-monorepo-with-lerna-yarnworkspace GitHub Wiki
对于一个庞大的大型项目,我们需要进行模块划分,拆分代码,这样才可以减少项目的复杂度,降低维护成本,采用模块依赖的方式,可以实现解耦,保持各模块的独立开发与维护。在管理这种由多个模块合成的大型项目时,有两种解决方案Multirepo和Monorepo,它们是两种不同的源码管理理念。
Multirepo的核心是一个模块一个仓库,每个模块都是独立开发与维护的,主工程仅提供必要的运行环境,业务模块全部依靠import,各业务模块分别属于独立的仓库,管控各自的版本发布。Multirepo是现在大多数项目采用的代码管理模式,这也和微服务的理念一致。
- 每个模块都拥有完整自主的开发和发布管理,模块之间很少甚至没有耦合;
- 模块之间采用import的方式进行依赖管理,当一个模块在本地修改的时候,其他模块需要采用npm link的方式进行开发调试,这使得当一个模块修改的时候,引发的依赖更新管理很困难。
- 版本发布很分散,当有一个模块进行发布更新时,需要人工自主评估其他模块是否需要跟进发布;
- 每个模块都可以自主设置Access Control,提高代码安全。
Monorepo则是将项目相关的所有模块都放在一个仓库中进行开发和维护。
- 在一个仓库中可以采用统一构建、CI、测试和发布流程,但是这也需要开发更复杂的构建工具来支持;
- 跨模块的操作和修改很容易,方便管理版本和依赖管理;
- 代码仓库的体积都比较大;
- 方便统一处理issue和生成ChangeLog;
- 出于代码安全层面考虑,Monorepo不能很好的控制各个模块的代码权限,无法做权限隔离,维护一个模块可以看到所有模块的代码;
当一个项目中各个模块之间存在紧密联系,但同时确实存在明显的业务边界,则比较适合采用monorepo的代码管理模式。而对完全不同的多个业务,每个模块很独立,功能不重合的,最好还是使用Multirepi的方式。
Lerna是业界知名度最高的Monorepo管理工具,根据其规范,我们制定了项目的目录结构(仅示意),每个模块的源码放在src目录下,构建结果在es/dist/lib目录,每个模块自有一个package.json,只记录着该模块自身的依赖,在项目根目录下统一维护的package.json则记录整个项目所有模块的构建依赖,比如统一的lint、test和打包工具。
.
|-- packages
| |-- module-A
| |-- src
| |-- es
| `-- package.json
| |-- module-B
| |-- src
| |-- dist
| `-- package.json
|-- config
|-- node_modules
'-- package.json
对于每个模块的构建产物,我们进行了如下的规范约束:
- src:源代码
- lib:commonjs规范的构建产物
- es:ESM规范的构建产物
- dist:umd规范的构建产物
那么相应的,需要在每个模块的package.json中进行如下声明:
{
"main": "lib/index.js",
"module": "es/index.js",
"browser": "dist/index.js"
}
- 全局安装Lerna
npm install -g lerna
- 初始化项目
lerna init
运行完毕则会生成packages文件夹、lerna.json和package.json文件,然后添加.gitignore、README.md和scripts等相关文件。
.
|-- packages
|-- scripts
|-- .gitignore
|-- README.md
|-- lerna.json
'-- package.json
在.gitignore中添加:
node_modules
es
lib
dist
项目根目录下生成的package.json中设置了private
属性,说明是私有仓库,不允许发布。
{
"name": "root",
"private": true,
"devDependencies": {
"lerna": "^3.20.2"
}
}
如果需要使用tnpm,则配置lerna.json:
{
"packages": [
"packages/*"
],
"version": "0.0.0",
"npmClient": "tnpm"
}
- 新建package
在项目根目录下执行lerna create来创建package,也可以手动创建。
lerna create <name>
执行完毕后会生成以下文件,然后可以手动添加自己需要的文件,比如src,以及根据情况修改package.json。
.
|-- packages
| |-- module1
| |-- __test__
| |-- src
| |-- lib
| |-- module1.js
| |-- package.json
| |-- README.md
| |-- module2
|-- scripts
|-- .gitignore
|-- README.md
|-- lerna.json
'-- package.json
- 添加依赖
虽然是同一个repo,但是每个package需要保持其独立性,package之间的依赖依旧要使用包名的方式,在各个package下的package.json中添加dependencies,在项目根目录下的package.json中添加devDependencies。例如上面module2需要依赖module1,那么直接在module2的package.json中添加依赖module1即可,接下来执行lerna bootstrap
,这会在项目根目录和packages下都安装各自的依赖,创建node_modules,packages下的相互依赖则是通过符号链接symlink来实现的,对开发调试很友好。
lerna bootstrap
会主动分析packages并自创根据package.json中的依赖声明来创建需要的npm link
,将模块链接到对应的运行项目中去方便本地开发与调试,
lerna bootstrap
会在每个package下安装添加的依赖包,可以采用--hoist
模式来减少重复安装external依赖,lerna bootstrap --hoist [glob]
会将匹配glob
的依赖包安装在项目根目录下,这样可以减少在各个package下重复安装。每个package下的node_modules/.bin/
目录会链接到根目录下,可以很方便执行npm scripts
。
lerna也提供add
命令来添加本地或者远程依赖包,可以参考官方的Usage。
lerna add <package>[@version] [--dev] [--exact] [--peer]
-
清理环境
$ lerna clean # 清理所有的node_modules $ lerna run clean # 执行所有package的clean操作,删除编译产物
lerna run <command>
会执行每个package下的对应命令。
-
项目测试
可以有两种测试方案:
- 在项目根目录下使用统一的测试配置,比如选择Jest,可以方便统计所有代码的测试覆盖率,但是如果每个package的运行环境差距很大,比如小程序、browser、node等等,这会使得测试配置很复杂。
- 每个package单独支持test命令,使用
lerna run test --parallel
,那么就无法统一收集所有代码的测试覆盖率。
我们希望所有的git提交能够遵循conventionalcommits规范,能够有类似下面这些开源项目中友好的commit message。
如果仅靠人工约束,这很难保证绝对不会出现问题,因此尝试使用了
commitizen
来进行规范。首先安装以下依赖:
npm install commitizen cz-conventional-changelog -D
然后配置使用git-cz
来替代git commit
,接下来在提交时候运行npm run commit
即可:
"scripts": {
"commit": "git-cz"
},
结合husky
+ lint-staged
来强制检测message是否符合要求:
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"packages/src/**/*.(js|jsx)": [
"git add"
]
}
Lerna天然支持conventionalcommits规范,每次修改完毕后执行lerna version --conventional-commits
,让lerna来更新各个package的版本,--conventional-commits
则表示自动生成CHANGELOG
。Lerna的版本管理有两种模式,我们采用了默认的模式:
- Fixed/Locked模式(默认):在lerna.json中维护统一的一个版本号,如果一个module有改动需要发布,则会更新到这个最新的version,每次更新版本的时候,每个package都是以这个统一的版本号为基准进行变化。
- Independent模式:每个package维护自己的版本号。
在每个package下都应该有对应的构建npm script,例如在每个package下的package.json中定义:
"scripts": {
"watch": "...",
"build": "...",
"lint": "...",
"test": "..."
}
在项目根目录下的package.json定义发布命令的npm script:
"scripts": {
"version": "lerna version --conventional-commits",
"prepublish": "lerna run build --stream",
"publish": "lerna publish from-git"
}
配置from-git
主要是用于结合CI平台进行publish,当需要进行发布时,首先在本地运行lerna version --conventional-commits
,执行完毕后lerna会自动进行一个git tag的提交,然后就可以配置CI平台对git tag执行publish的操作(结合CI平台的发布,还在研究中)。
Yarn是由Facebook、Google、Exponent和Tilde联合推出了一个新的JavaScript包管理工具,用于解决npm存在的一些问题,速度更快,可以使用Yarn来替代npm。npm(1)和yarn(2)拥有共同的一些功能和特性(3),根据官方的说明yarn workspaces(4)只会提供一些workspace的基础命令,一些高级命令还是要使用lerna来完成,yarn workspaces应该结合lerna一起使用,无法替代lerna。
修改项目根目录下的package.json,设置workspaces:
{
"name": "root",
"private": true,
"devDependencies": {
"lerna": "^3.20.2"
},
"workspaces": [
"packages/*"
]
}
修改lerna.json,使用yarn命令进行构建:
{
"version": "0.0.0",
"npmClient": "yarn",
"useWorkspaces": true
}
依旧可以使用lerna的各种command,也可以使用下面这几个基本的yarn命令:
-
yarn install
等同于lerna bootstrap --hoist
,区别在于yarn install
会将所有的依赖都安装在项目根目录下,包括本地依赖,比如上面module2依赖module1,那么module1不会被symlink在module2下的node_modules下,而是都放在项目根目录下。 -
yarn workspace <workspace_name> add <package>
用于添加依赖,当添加本地依赖的时候有可能会遇到这个错误Couldn't find package
,这是yarn的一个issue,解决方案有这样两个:- 指定本地local依赖的版本号;
- 使用
yarn install
来安装依赖。
-
yarn workspaces run <command>
会执行每个package下的命令,和lerna run
类似,但是yarn workspaces run
不支持按照依赖的拓扑排序规则执行命令,这一提案已经被Accepted但还未实现,这可能会影响构建结果,比如module2依赖module1,必须在module1构建完成后才能构建,这个时候就只能用lerna run --stream build
,lerna run
还具有更多高级的设置,因此建议使用lerna run
。
从上面的分析可以得出以下的使用规范,简而言之,就是使用yarn workspaces来管理整个monorepo项目的依赖,lerna则用于构建和发布。
- Yarn用于处理项目的依赖管理;
- Lerna用于运行项目的lint、测试、构建和发布命令等,lerna run的功能要比yarn workspaces run更强大;
- 在packages中一个package一个文件夹,所有的package的目录结构一致;
- 每个package中只定义其运行时依赖
dependencies
; - 所有的构建开发依赖
devDependencies
在项目根目录下共享; - 所有的package共享版本号,使用
lerna version
进行统一的维护和管理。
yarn config set proxy "http://127.0.0.1:12639"
yarn config set registry "http://r.tnpm.oa.com/"