基于lerna的monorepo改造现有项目实战总结
温馨提示
:本文不是介绍lerna
的使用,而是着重基于lerna
的monorepo项目改造过程遇到的问题以及总结复盘。
背景
有这样的一个已经迭代了近3年的小程序项目,因为业务的整个交易流程是在不同平台进行复用的,单独抽离了一个业务npm包business-sdk
,通过npm包的方式引入到不同平台。
由于该业务是本人所在部门维护的,并且也会部署到我们自己的一个平台上,所以在npm业务包进行需求迭代时,是在我们平台项目上通过npm link
进行本地开发调试的。这种multi-repo
开发方式,随着频繁的迭代其劣势从开发、npm发版到上线的整个过程中体现的越来越明显:
-
开发阶段
这个阶段主要体现以下4个方面痛点:
- 开发一个需求至少执行一次
npm link
;开发前需在应用App中使用npm link
建立对业务npm包的软链,而安装新npm依赖时会导致软链失效,需要重新执行该操作。 - 多分支切换易遗漏出错; 业务开发时App和npm包要单独各建分支,在并行2个项目开发时在4个分支间切换,3个以上极容易遗漏出错
- 代码风格不统一;App应用跟npm包的代码规范不一致,开发比较困惑
- 开发一个需求至少执行一次
-
包发版
这个阶段主要的问题是:npm业务包发版,需要开发关注发版细节:
- 执行
npm version patch|minor|major
修改npm包版本号并git commit - 需要人肉git push
- 执行
-
上线
npm包发版后,这个阶段的问题需要在应用项目中更新最新业务包:
- npm install business-sdk@xxx
- 打包编译输出产物
- 小程序上线
这个过程会导致之前操作的
npm link
失效。
总结一下,一个项目从开发到上线的过程是这样的:
整个流程开发者都要全程主动参与,流程长,那么能否将流程中需要开发者参与的一些步骤自动化呢,从而将流程简化为:
这样,开发者可以将精力主要集中在业务开发上,从流程上提升开发效率?
答案是肯定的,monorepo
方案是一个不错的选择。
为什么选择monorepo方案
monorepo
方案优势正是multi-repo
的劣势,它可以让多个模块共享同一个仓库,带来的好处:
- 可以共享同一套构建流程
- 统一代码规范
- 在模块间存在相互依赖的情况下,查看代码、修改bug、开发调试等会更加方便
因此也越来越受到大家的关注,像 Babel
、React
、Vue
等主流的开源仓库都采用的 monorepo
。
实现monorepo
的方案很多,我们选择社区比较成熟的lerna
方案,它内部完全接管业务npm包的开发、版本依赖、版本发布等维护工作,即:
- 调试方便,npm模块之间的相互引用,lerna内部会自动进行
npm link
- npm版本管理方便,npm发版后,它会自动维护npm版本以及依赖该npm模块的依赖方版本的更新
- 易于统一代码风格&提交规范
所以,基于上面的原因选择基于lerna
的monorepo
方案。
改造方案
目录结构
项目新建一个repo用来充当monorepo,其目录结构是这样的:

其中目录apps和packages为lerna
的packages目录,为了保留原有项目的git commit历史记录,通过lerna import
命令来迁移原有项目。其中:
apps
目录表示的是主应用目录,原应用项目App迁移到到目录下;它不参与npm发版(应用的package.json
中的private
设置true)packages
目录为共享可复用的package存放位置,参与npm发版;原business-sdk npm包代码迁移到该位置。
开发阶段
开发前,首先需要安装各个package的依赖。
项目使用lerna bootstrap --hoist
将各个package的依赖提升至根目录下,依赖提升有两个的原则:
- 单个package独有的依赖会提升至根目录的node_modules下
- 多个package的相同依赖,若版本相同则提升根目录;不同版本时,则会将最常用的版本提升;
另外,针对main应用依赖business-sdk包的情况,lerna
会自动帮我们在main应用建立business-sdk包的link,无需开发者关注。
启动项目时,可以继续使用原先App应用中在package.json
中配置的scripts
功能,只需要在项目根目录下的package.json
新建scripts
即可。如启动本地开发模式的脚步如下,它会执行主应用包中的start
脚步:
{
"scripts": {
"start": "lerna run --stream --scope=main start",
...
},
}
这样,我们在项目根目录下执行如下命令就可以启动本地开发了。
npm start
发版阶段
发版就涉及到npm包的版本维护更新问题,lerna
有两种模式来维护版本:
- 独立模式:项目每个package的版本各自维护,在其旧版本的基础上进行累加
- 固定模式:基于
lerna.json
中的version
值来升级所有变更包的版本
针对固定模式,举例来说,初始项目lerna.json
中的version
的值默认为0.0.0
,
packageA改动并发版后,lerna.json
中的version值会递增为0.0.1
,同时packageA的最新版本变为0.0.1
;后面packageB改动发版时,其最新的版本号是在lerna.json
中version值的基础上累加,变更为0.0.2
,lerna.json
的版本也更新为该值。
可以看出固定模式存在一个问题是packageB的历史版本号缺少连续性,比如上面就缺少0.0.1
版本。
项目使用lerna
的独立模式进行版本管理。lerna
是通过执行lerna publish
来进行某个package发版,它会自动帮我们完成如下操作:
- 修改package的版本号,并在依赖它的其他package中更新该依赖的版本号
- git commit & git push
- npm publish
使用lerna publish
来进行发版时,需要注意以下几点:
-
发版到私有npm源
可以用
lerna publish --registry url
来指定私有源地址,也可以在lerna.json
中配置publish
命令的参数:{ "command": { "publish": { "registry": "http://xx.npm.registry.com/" } }
-
更细粒度的版本号管理
lerna publish
发版时,默认是按照npm version patch
来生成新的版本号,若想细粒度的控制package发版的版本号,可以为lerna publish
指定npm version的参数,如下所示:lerna publish [major | minor | patch | premajor | preminor | prepatch | prerelease]
若想先发布预发版本,在测试正常在发正式版本,可以这样使用:
# 发预发版 lerna publish --conventional-commits --conventional-prerelease
使用上面命令可以多次发布预发测试版,在最终没有问题了,可以将最新无问题的预发版发布为最新上线版本。
# 将预发版发布为正式版 lerna publish --conventional-commits --conventional-graduate
统一代码规范 & 自动生成changelog
1、eslint和prettier统一代码规范
二者的区别是,eslint
即可代码格式化,又可进行代码质量检查;而prettier
只能进行代码风格检查。项目中使用eslint
来完成代码质量检查,而prettier
负责代码格式化,二者在格式化方面有冲突的地方,如何保证在vscode协调工作,可以参考这篇彻底搞懂ESLint与Prettier在vscode中的代码自动格式化。
配合husky
在提交代码时强制代码检查,提交代码前处理。检查存在不符合代码风格的则不允许提交代码,从而达到代码风格的统一和质量检查。其中package.json
有关husky
的配置如下:
{
"scripts": {
"lint": "eslint $(git diff HEAD --name-only | grep -E '\\.(js|ts|wss|mpx)$' | xargs)"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"{packages,apps}/**/*.{js,ts,wss,mpx}": [
"npm run lint --fix"
]
},
}
为提前进行代码格式化,可以在项目根目录新建.vscode/setting.json
文件,配置文件保存时就进行格式化:
{
// 关闭代码保存自动格式化,防止使用prettier格式化,因为eslint会使用prettier进行格式化
"editor.formatOnSave": false,
// 代码保存时,自动使用eslint进行格式化
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
2、自动生成changelog
为package生成changelog的好处:项目迭代频繁,changelog可以用来记录每个版本的迭代功能、bugfix等情况,对项目的迭代有一个清晰的时间脉络,对项目回溯有一定的必要性。
那怎么自动生成changelog? 很简单只需执行如下命令:
lerna version --conventional-commits
# or
lerna publish --conventional-commits
lerna
会根据传统的提交规范来自动生成changelog。默认情况下,lerna
自动生成changelog 的预设值是angular,其提供的提交类型有如下几种:
所以,git的commit message必须要按照以上指定的规范来提交, 否则无法生成changelog。但是约定归约定,开发者不一定遵守或者新人接手时不熟悉未必按照这个规范来提交,这就需要强制约束了。社区提供的commitlint就是对开发者的commit message进行规范检查的,项目中需要安装有关commitlint的两个npm包:
npm install @commitlint/cli @commitlint/config-conventional -D
提交规范的检查时间点可以跟eslint检查代码风格一样,在代码提交前进行检查,检查失败则不允许提交。
在package.json
中的其配置如下:
{
...
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"{packages,apps}/**/*.{js,ts,wss,mpx}": [
"npm run lint --fix"
]
},
...
}
另外,需要注意一点,在通过lerna publish
发布包时,它会自动修改package的版本号,并自动commit & push到git仓库,这时需要对commit的message按规范指定,否则会导致leran publish
失败,一般在leran.json
中publish命令中进行如下指定:
{
"command": {
"publish": {
"message": "chore(release): publish"
}
}
改造过程遇到的问题
1、现有git仓库迁移monorepo,要保留git commit的提交记录
既然是重新新建一个repo对现有项目进行monorepo
改造,那么将已有的两个repo项目迁移至新建repo,若copy整个项目的话,则在提交时会丢失原有项目的git commit历史记录,若想保留原有项目的git提交记录怎么办呢?
lerna
想到了这方面的使用场景,提供了lerna import
命令,其作用:
将一个带有git提交历史记录的模块导入到
monoreopo
的packages中
这样,通过该命令将基于git commit
的package引入到当前 lerna
下面的 packages ,成为当前项目目录的一个包,然后统一管理。
具体的来说,在当前项目执行两次lerna import
命令,即:
# 导入主app应用包至当前项目app目录
lerna import AppPath --preserve-commit --dest=apps --flatten
# 导入业务包至当前项目packages目录,默认导入到packages目录
lerna import businessSdkPath --preserve-commit --flatten
温馨提示,lerna import
需要注意的3个地方:
-
执行该命令导入外部模块前,工作区应没有变动未提交的内容
-
该命令的
--dest
参数指定导入的目录必须已经存在,否则会报错。例如,将sdk外部模块导入到当前项目的
packages
目录下,执行如下命令:lerna import ~/../sdk --preserve-commit --flatten --dest=packages/sdk
若
packages
目录下没有sdk目录,则该命令会报该错误:lerna ERR! EDESTDIR --dest does not match with the package directories: packages
-
若要获取外部模块最新的git commit内容,该命令需要使用
--flatten
参数,否则导入带有冲突合并提交的外部包时,导入的代码不是最新的,会丢失冲突合并后的代码。 -
lerna import
导入的外部git仓库比较简单:一是导入本地git仓库,并且导入的仓库只有git log,没有分支和tag信息;二是导入时不能修改git仓库名称;若有这方面的使用场景,可以使用tomono进行迁移。
2、lerna bootstrap --hoist并没有安装package的依赖
lerna
提供bootstrap
命令完成2个主要功能:
npm install
各个包的依赖- 所有相互依赖的package建立符号链接
其中,在安装各个包的依赖时,lerna
提供2种方式:
- 每个包各自安装各自的依赖
- 将指定或者所有package的依赖进行提升到根目录
这正是lerna bootstrap
命令的参数--hoist
的功能;顾名思义,指定该参数时则在安装项目依赖时会选择上面的第二种方式。但是在使用lerna init
的项目中,执行命令:
lerna bootstrap --hoist
lerna
提示:lerna info bootstrap root only
。给出的提示说只安装项目根目录的依赖,而各个package的依赖却没有安装。这种结果让人摸不着头绪,无奈去查看bootstrap命令的源码到底干啥了,发现这么一段逻辑:
if (this.options.useWorkspaces || this.rootHasLocalFileDependencies()) {
return this.installRootPackageOnly();
}
若useWorkspaces
配置项为true就会只安装root的依赖,为什么会配置为true呢?它是干什么用的呢?
带着这两个疑问去调研发现:
-
为什么会配置为true
最新版本的
lerna
在执行lerna init
命令后,其生成的lerna.json
配置文件中会将useWorkspaces
设置为true -
它是干什么用的呢
该配置项是为了配合
yarn
的workspace来使用的:- 值为true,相当于使用
lerna bootstrap --use-workspaces
,表示lerna.json
中packages
将被package.json/workspaces
的值覆盖,源码如下所示;get packageConfigs() { if (this.config.useWorkspaces) { const workspaces = this.manifest.get("workspaces"); ... return workspaces.packages || workspaces; } return this.config.packages || [Project.PACKAGE_GLOB]; }
- 值为false,则
lerna
和yarn
会分别管理各自的packages路径。
- 值为true,相当于使用
至此真相大白,lerna
初始化时npmClient
会默认使用npm,但却配置的了yarn
的workspace的相关配置("useWorkspaces": true
),从而导致npm无法安装package的依赖。
温馨提示:lerna bootstrap
命令有无--hoist
参数其表现尤其需要关注一下:
没有
--hoist
选项时,是不会安装root的依赖,只会安装各个package的依赖,并且会生成各个包的package-lock.json;有该选项时则包括root目录依赖都会安装,但是安装各个package时不会根据每个包的
package-lock.json
的情况来安装
3、lerna运行package的script脚本时会丢弃脚本的输出信息
在通过lerna run xxx
命令执行各个package的package.json
中的scripts
配置脚本时,若script脚本有输出信息,例如webpack构建信息, lerna
默认是不会在标准输出中输出这些信息的。
为了解决这一问题,可以执行命令时添加--stream
参数即可。
lerna run xxx --stream
4、webpack构建配置调整
项目在multi-repo
方式下,业务npm包是不会单独通过webpack构建的,其发版内容就是开发的源码;它是作为App应用的一个依赖模块参与到App的webpack构建打包流程,所以在App主应用webpack配置中需要将该业务包的内容包含进来,如下面是webpack的js配置部分:
function resolve(dir) {
return path.join(__dirname, '..', dir);
}
// webpack的配置如下:
...
module: {
rules: [
...
{
test: /\.js$/,
loader: 'babel-loader',
include: [resolve('src'), resolve('node_modules/business-sdk')]
},
...
]
}
...
经过monorepo
改造后,因为目录结构的调整,所以App中的webpack配置也发生变更,同理webpack引入业务npm包的地址也发生变更,
...
module: {
rules: [
...
{
test: /\.js$/,
loader: 'babel-loader',
include: [resolve('src'), resolve('../../node_modules/business-sdk')]
},
...
]
}
...
同样的,对于执行lerna bootstrap --hoist
命令后,package的依赖提至根目录,那么webpack若有这些依赖包的配置,一样需要手动变更为根目录的node_modules,否则会导致webpack构建时报错。
总结一句话:依赖安装提升后,原有的webpack构建涉及到配置业务npm包的node_modules的情况都需要变更为根目录的node_modules。
5、自动生成changelog输出Version bump only for package
的问题
某些情况下,package自动生成的changelog
没有任何内容,除了提升Version bump only for package
,其表明当前package因其依赖版本的更新导致其版本的升级,除此之外该package没有任何变动。
例如模块A依赖了模块B,B版本更新了,导致A升级B的最新版本而生成A的changelog就会只有前面提到的版本提示信息。
这种情况暂时还无法屏蔽生成,有人已经在github上提到该issue # support ignore "Note: Version bump only for package" when generate changelog.md in independent mode。
既然无法屏蔽,换一种思路,能否指定为具体某些package自动生成changelog呢?
答案是lerna
也是不支持的
6、lerna
固定模式与独立模式生成changelog的区别
- 固定模式,会为每个改动的package以及依赖它的package都自动生成changelog,包括根目录
- 独立模式,除了不会为根目录生成changelog之外,其他同固定模式
7、如何修改自动生成changelog中的提交信息
自动生成的changelog文件内容一般如下图所示:

默认情况下,lerna
只有feat
、fix
和perf
三种类型的消息会生成的changelog的内容。若package有变动但提交message不是这三种提交类型时,则自动生成的changelog内容为Version bump only for package xxx
。
若要修改生成changelog的类型,可以在lerna.json
配置command.version.changelogPreset
进行自定义changelog的预设设置
"version": {
"changelogPreset": {
"name": "conventionalcommits",
"types": [
{ "type": "feat", "section": " Features" },
{ "type": "fix", "section": " Bug Fixes" },
{ "type": "perf", "section": "⚡️ Performance Improvements" },
{ "type": "revert", "section": ":rewind: Reverts" },
{ "type": "style", "section": "Styles"},
{ "type": "docs", "section": "Documentation", "hidden": true },
{
"type": "chore",
"section": "Miscellaneous Chores",
"hidden": true
},
{
"type": "refactor",
"section": " Code Refactoring",
"hidden": true
},
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "build", "section": "Build System", "hidden": true },
{ "type": "ci", "section": "Continuous Integration", "hidden": true }
]
}
}
如上配置的结果是feat
、fix
、perf
、style
、revert
类型会形成 changlog,其他的不会生成changelog,也就是将对应类型设置hidden:true
生成changelog就会忽略该类型的信息。
另外,通过上面方式设置lerna
changelog的自定义预设,也可以自定义提交类型,如新增新类型或者删除已有类型。
monorepo改造收益
项目的monorepo改造,收益是比较明显的,主要体现在:
- 正如我们要达到的目标,简化了需求开发到上线的流程,平均每个需求减少
10min+
,开发者只关注业务开发 - 统一代码规范和git的commit message规范,减少bug的发生率
- 自动生成changelog,可以对项目的迭代回溯有一个清晰的时间脉络
参考文献:
转载自:https://juejin.cn/post/7155098034571837447