Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

在monorepo项目中怎么组织和优化前端研发流程? #92

Open
cumt-robin opened this issue Jun 18, 2024 · 0 comments
Open

在monorepo项目中怎么组织和优化前端研发流程? #92

cumt-robin opened this issue Jun 18, 2024 · 0 comments

Comments

@cumt-robin
Copy link
Owner

专栏上篇文章传送门:在本地和CI/CD中支持npm免登录发布

本节涉及的内容源码可在vue-pro-components c9 分支找到,欢迎 star 支持!

本文是 基于Vite+AntDesignVue打造业务组件库 专栏第 10 篇文章【你知道怎么组织和优化前端研发流程吗?】,前面几篇都在说函数库开发的相关内容,所以本文接着围绕这块说,主要是把研发流程梳理清楚,方便后续更多内容的铺开。

梳理研发流程

我们先粗略整理一下函数库的主要研发流程。

  1. 写代码,不限于需求/缺陷/优化等内容。
  2. 做一次 commit。
  3. 修改版本号。
  4. 生成 changelog。
  5. 本地打包发布 + 提交到 github;或者是,提交到 github + CI/CD 打包发布。

以上是主要流程,其他的辅助事项可以按需穿插,比如提交代码前是不是经过 husky, eslint, prettier, stylelint, commitlint 等。

版本号处理

第一步显然是跟工具无关的,纯粹是开发者自己写代码。

先说重点,第三步是修改版本号,我们来分情况讨论一下。

如果是单包工程,其实只有一个版本号要管理,第一种方式是手动改版本号,不借助任何工具,就相当于把第三步的修改版本号第一步的写代码放在一起做了。第二种方式是让工具去决定版本号,但工具怎么知道你期望的版本号是什么呢?这就必须先有规范。

首先要有版本号的规范,有了版本号规范才能知道下个版本号有哪些选择,这对应 Semver(Semantic Versioning)规范。

接下来还要有一套规范,能根据用户的输入或者操作推导出下一个 Semver 版本号。

一种做法是使用 npm version 命令,它支持 major/minor/patch 等版本更新操作,还支持通过钩子把 changelog 和后续的自动化流程全部做了,我之前有写过一篇前端自动化部署的深度实践中有提到,大家可以参考着看看。但是这还是需要我们自己决定到底是 major/minor/patch 的哪一种版本更新,无法完全自动化。

还有一种做法是基于 Git Commit 来实现自动化推导版本号,只要我们的 commit 符合 Conventional Commits 规范,通过分析两个版本之间的所有 commit 信息,就有机会推导出下一个版本号。

image.png

按照上图中提供的信息,我们可以知道,fix 类型的 commit 关联着 patch 位的版本号更新,feat 关联着 minor 位的版本号更新,Breaking CHANGE(具体实现是在 type(scope) 后接!,或者在 message footer 中使用Breaking CHANGE: )关联着 major 位的版本号更新。

基于此,一些自动化工具也应运而生,比如基于 Conventional Commits 生成 changelog 的底层 API —— conventional-changelog,以及一些上层工具 standard-version, semantic-release,还有我们相对熟悉的 commitizen + cz-conventional-changelog + husky + commitlint + npm version + conventional-changelog-cli 组合拳。

  • standard-version 专注于 version bump、生成 CHANGELOG.md、打 tag 等事项,支持生命周期钩子,可以做一些自动化流程。
  • semantic-release 除了上述能力,还会执行 git push,npm publish 等操作。
  • commitizen 提供了 git cz 命令,可以提供交互式命令行操作,用于替代 git commit 操作,按照 git cz 流程提交的 commit 就是比较规范的。
  • cz-conventional-changelog 则是 commitizen 家族的一份子,作为适配器的角色,用于实现 AngularJS's commit message convention,Angular 的 git 提交规范也算是业界扛把子了。
  • husky 是一款 git hooks 工具,支持 git 的所有钩子,我们可以用它来校验 commit message,也可以用来触发 eslint 等校验。
  • commitlint 对 git commit 信息做校验,因为你不能保证大家都守规矩,每次都会乖乖地用 git cz 提交,那么至少要校验 git commit 的输入信息是大致符合规范的。commitlint 也支持 configuration。
  • npm version 命令可以进行 version bump,但是需要你做出选择 major/minor/patch。
  • conventional-changelog-cli 则是最终用来生成 CHANGELOG.md 文件的。

在单包工程中,适当选择以上部分工具已经足够自动我们推导出下一个版本号了。而在 monorepo 工程中会存在多个子包,多个子包的版本号如何确定呢?

以 lerna 为例,有两种版本策略,具体见组件库技术选型和开发环境搭建文中相关介绍。如果我们采用 Fixed Mode,也就是 monorepo 工程中各个子包都共用一个版本号,那事情就简单得多,因为这跟单包工程没什么差别,只要根据 git commit message 简单推导出下个版本号即可。

如果我们选择 Independent Mode,也就是各个子包采用独立的版本号,那么 version bump 这件事情就变得复杂起来,因为我们在一次 commit 中可能不止修改了一个子包(毕竟是人为操作),产生耦合的几率比较大,版本界限不是很清晰。一次 commit 到底对应哪个子包的版本,谁都不好说清楚,因为我们得分析每次 commit 到底修改了哪些文件才能得出结论。

还好 lerna version 已经支持这个能力,只要我们执行下面的命令:

lerna version --conventional-commits --yes

lerna 就会遵循 Conventional Commits 规范,自动帮我们进行 version bump,生成相关的 CHANGELOG.md 文件。

husky + lint

说完最重要的版本号问题,我们再回到第二步,第二步是 commit,commit 环节可以穿插一些工具。

我们先补齐一些代码校验脚本,便于在合适的时间调用。

代码校验主要是通过 eslint 和 stylelint 完成,prettier 则是以插件的形式存在,被 eslint 和 stylelint 调用。

"lint": "eslint packages --cache --ext .js,.mjs,.jsx,.ts,.tsx,.vue",
"lint-fix": "eslint packages --cache --fix --ext .js,.mjs,.jsx,.ts,.tsx,.vue",
"lint-style": "stylelint packages/**/src/**/*.{vue,css,less} --cache",
"lint-style-fix": "stylelint packages/**/src/**/*.{vue,css,less} --cache --fix"

主要脚本如上所示,其中lint只负责 lint,不进行 fix;lint-fix会在 lint 时顺手修复问题;lint-stylelint-style-fix同理。

我们期望在提交代码前进行代码质量校验,这需要用到 git hooks 中的 pre-commit 钩子,在 pre-commit 钩子中可以执行 eslint 等 lint 命令。

husky 对 git hooks 进行了良好的封装,我们根据指引安装一下。

image.png

// 由于我们当前使用的是 Yarn 1,所以可以执行以下命令安装
npx husky-init && yarn

按道理,我们只要新增一个 pre-commit 钩子,执行相关的 lint 命令即可。但是,每次 commit 都 lint 整个工程的文件是比较浪费时间的,所以我们可以再引入一个 lint-staged 进行优化,lint-staged 只会 lint 进入了 staged 状态的文件,这样效率就比较高。

// 安装依赖
yarn add -DW lint-staged

lint-staged 通过配置文件决定具体要对哪些文件执行哪些脚本,我们新建一个lint-staged.config.js配置文件。

module.exports = {
    "packages/**/src/**/*.{js,mjs,jsx,ts,tsx,vue}": "eslint --cache --fix",
    "packages/**/src/**/*.{css,less,vue}": "stylelint --cache --fix",
};

接着把.husky/pre-commit文件的内容改为:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged $1

所以,完整的逻辑是:

image.png

规范 commit

首先安装一下 commitizen 及相关依赖:

yarn add -DW commitizen cz-conventional-changelog

然后在package.json中加入以下配置:

"config": {
    "commitizen": {
        "path": "cz-conventional-changelog"
    }
}

接着就可以正常使用git cz命令了。

image.png

但是,即便引入了 commitizen,我们也不能保证开发者一定会使用 git cz 来规范自己的行为,所以我们可以再利用 git 的 commit-msg 钩子,再配合 commitlint 验证开发者提交的 commit 信息。

yarn add -DW @commitlint/config-conventional @commitlint/cli

新增一个配置文件:

echo "module.exports = {extends: ['@commitlint/config-conventional']};" > commitlint.config.js

接着在.husky目录下新增一个commit-msg钩子。

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx --no-install commitlint --edit $1

此时不规范的 commit 就是无法通过的。

image.png

回顾流程

我们再来回顾和梳理一下流程:

  1. 开发代码
  2. git cz 交互式 commit
  3. husky + pre-commit + lint-staged 进行必要的 linter 校验
  4. husky + commit-msg + commitlint 进行 commit 校验
  5. 通过 lerna version 进行 version bump,并生成 changelog 和 github release,最后 push 到 github。
  6. 在 github actions 中执行打包和发布流程。

2 ~ 4 都是提交代码时触发的,针对第 5 步可以单独写个 script,比如:

"bump-version": "lerna version --conventional-commits --create-release github --yes",

第 6 步是通过 github actions yaml 文件配置的,执行的主要脚本就是打包构建以及发布到 npm。

"release:ci": "yarn buildBatch && yarn publish:package",

针对 github actions 的触发条件,我优先考虑的是 github release 的创建。

on:
  release:
    types: [created]

lerna version --create-release github 命令的执行会触发 github actions workflow。

但是在使用的过程中,我也发现一个问题,lerna version 不仅会修改真正发生内容变化的子包的版本号,还会修改 workspaces 中引用了这个子包的其他子包的版本号。

这样说可能也不好理解,举例说明一下。

假设我在一次开发过程中仅仅给@vue-pro-components/utils加了一个功能,在执行 lerna version 命令时,它的版本号minor位会加 1,这合情合理;

由于vue-pro-components以及@vue-pro-components/headless这两个包都引用了@vue-pro-components/utils,所以它们俩的package.json中的依赖@vue-pro-components/utils的版本号也会升一级,因此它们俩自身的版本号也会随之更新。

此时会生成三个 tag,并发布三个 github release,分别是@vue-pro-components/[email protected], @vue-pro-components/[email protected], [email protected]

其实创建三个 release 也没啥问题,因为我采用的是 lerna 的 Independent Mode,各个子包版本号独立;但是考虑到我的 github actions 的触发条件是release 的创建,这也意味着三个 release 会触发三次 github actions workflow,虽然三次 workflow 执行完的结果是一样的,但是完全没必要做重复的工作,按需使用是我们的宗旨。

目前,lerna version 这个行为还没有参数可以用来控制开关,但是并不是说这是 lerna 的问题,也许我们可以改进自己的流程来规避这个问题。

改进流程

idea 1

接上面,我的第一个想法是,在不同的 release 对应的 workflow 中取出 release name,release name 中有包名的信息,自然就可以基于此按需打包发布。

image.png

但是,这也存在一个问题,实际上,包之间是有依赖关系的,也就意味着在某些工序上可能有先后顺序。

如果所有子包都各自独立打包,其实是有问题的,比如当多个 release 对应的 workflow 同时进行时,如果包 A 依赖的某个包 B 还没打包并发布到 npm registry,就有可能导致 A 打包出错。

所以最好的办法还是按依赖关系决定的顺序,放在一起打包发布。

idea 2

我的第二个想法是:执行 lerna version 的时候不要创建 release,也就是不带--create-release参数。接着再通过其他脚本或工具给整个工程打个 tag 和 release。这样一来,一次发布过程就只会产生一个 release,因此也只会执行一次 github actions workflow,看起来还比较符合我的心意。

我们考虑引入 release-it,用它来重新组织流程。

image.png

我们这里用到了一个插件 @release-it/conventional-changelog,它很重要。

我们再理一遍流程:

  1. 首先还是写代码。
  2. 接着通过 git cz 做一次 commit。
  3. 经过必要的钩子检查。
  4. 开始执行 release-it,我们先利用 release-it 的before:init钩子执行packages-bump-version命令,packages-bump-version命令对应:
lerna version --conventional-commits --no-private --yes

其实就是在原来的基础上去掉了--create-release github。执行这条命令会更新 packages 目录下各个包的版本号,并为各个子包更新 CHANGELOG.md 文件。

  1. 接着 release-it 根据 git log 确定一份 changelog 信息,用于辅助后续过程。
  2. 由于插件 @release-it/conventional-changelog 实现了getChangeloggetIncrementedVersionCI方法,结合起来决定了下一个版本号,具体到内部逻辑,其核心是用到了 conventional-recommended-bump 这个包,它能基于 conventional commits 规范给出建议的 releaseType(对应 major, minor, patch 等),再结合 semver.inc,就能得到下个版本号。

image.png

  1. 接着就是执行 release-it 插件的各个钩子,以及收尾的releaseafterRelease钩子。其中核心插件 npm 执行了关键的bump钩子,通过npm version更新了 package.json 文件中的 version 字段;插件 @release-it/conventional-changelog 用到了beforeRelease钩子来生成 CHANGELOG.md,其中用到了我们在上面提到的 conventional-changelog 这个基础包。

CHANGELOG.md 不符合直觉

试用了上面的流程之后,总体感觉还好,没什么明显问题,但是我发现根目录下 CHANGELOG.md 的生成不符合我的直觉。

由于我在 0.2.0 版本中提交了一个 feat 类型的 commit,相关的 Features 记录应该要体现到 CHANGELOG.md 中,但是结果并没有。

image.png

image.png

我发现这是因为 lerna version 虽然去掉了--create-release参数,没有再创建 release,但是 tag 还是打出来了。这就会导致 release-it 在对比 0.2.0@vue-pro-components/[email protected] 两个 tag 的差异时,找出的 commits 只是 chore 类型的 release 说明,比如:

chore: release v0.2.0

chore类型的 commit 记录不足以体现到 CHANGELOG.md 中,这与 Conventional Changelog Configuration Spec 有关。

image.png

所以要想办法去掉 lerna version 创建 tag 的行为。

我查了一下 lerna version 的文档,发现有一个参数--no-git-tag-version看起来比较贴合我的需求,用了一下发现,它的行为是既不提交 commit,也不打 tag。而不做 commit 就会导致 git 工作区不是 clean 状态,这会导致后续的 release-it 流程无法继续。release-it 也有个配置项git.requireCleanWorkingDir可以关闭 git 工作区 clean 的检查,不过我暂时不打算这么做。

image.png

我的思路是:由于我的目的还是去掉 lerna version 创建 tag 的行为,所以还是要使用 --no-git-tag-version这个参数,但是我紧接着会自行执行一次 commit,用于保持 git 工作区的 clean 状态。所以我把关键脚本改为下面这样了:

"packages-bump-version": "lerna version --conventional-commits --no-git-tag-version --no-push --no-private --yes",
"commit-packages-version-info": "git add . && git commit -m \"chore: bump packages version\"",
"determine-packages-version": "yarn packages-bump-version && yarn commit-packages-version-info",

release-it 的before:init钩子执行的脚本变成:

"before:init": "yarn determine-packages-version",

这就对应我上面说的思路,把一个完整的脚本拆成两个,第一个还是调用 lerna version,第二个变成调用我自己定义的 git add 以及 git commit 命令,基于此绕过创建 tag 的行为。

按这个流程工作,根目录下生成的 CHANGELOG.md 变得正常,但是......

顾此失彼

当我以为万事大吉时,却发现,按照这个方案实践时,虽然根目录的 CHANGELOG.md 正常了,但是各个子包中的 version bump 以及 CHANGELOG.md 都变得不正常了,我们来分析一下 lerna version 为什么会出问题。

由于我加上了--no-git-tag-version参数,这就会导致 lerna version 不会为各个子包打上特殊的类似于@vue-pro-components/[email protected]的 tag,这会引起一些问题。为了搞清楚问题原因,我们来分析一下流程。

经过 debug 发现,lerna version 会经过一些关键的节点。

  • @lerna/version/index.js 中的getVersionsForUpdates以及recommendVersions
  • @lerna/conventional-commits 中的recommendVersion
  • conventional-recommended-bump 中的gitSemverTags
  • git-raw-commits 中的gitRawCommits
  • conventional-commits-parser 中的conventionalCommitsParser

其中gitSemverTags决定了程序会查询哪些 tags,这里有一个关键的lernaTag函数,执行完lernaTag函数后,tags的值是一个空数组,这是因为我们放弃了打特殊 tag。

image.png

如果tags是一个空数组,就会影响后面的gitRawCommits函数的执行,导致from参数是空的,这就意味着程序会读取整个 git log。

image.png

image.png

这就意味着:不管我最近一次改的是什么内容,只要 git log 的历史记录中有 Breaking Change,下个版本号就会是大版本更新,同理,只要 git log 中有 feat 类型的 commit,下个版本号就会更新 minor 位。同时子包每个版本的 CHANGELOG.md 都是“大而全”

image.png

这基本上就崩盘了,版本号都不对了。看来针对各个子包的特殊 tag 还是不能少,否则 lerna 也无法正确分析出下个版本号,所以--no-git-tag-version还是不能加,但是去掉又会发生上一节说的问题,怎么想办法解决一下呢?

我的思路是:release-it 能不能只分析0.0.0这种格式的 tag 之间的差异,因为这种格式的 tag 是 release-it 针对整个工程打的,分析这两个 tag 之间的 commit 肯定是能够正确反映出整个工程的版本更新情况的。

image.png

经过调试发现,核心的问题在于getChangelogStream方法中,latestTag的值为 lerna version 针对某个子包打出的 tag,能不能想办法让他变成 2.1.0 呢?

2.1.0 是调试时整个工程的版本号。

image.png

首先是要找到latestTag是在哪里被赋值的,自然是优先在 release-it 的一些核心插件中去找,很快能找到目标模块 GitBase.js,其中的getLatestTagName方法决定了什么样的 tag 会作为候选目标。

image.png

这里有一行很关键的代码:

git describe --tags --match=${match} --abbrev=0

--match是我们能通过参数tagMatch控制的,我们参照配置清单release-it.jsongit.tagMatch配置好,仅匹配数字开头的 tag 即可。

image.png

经过这波优化,根目录和子包中的 CHANGELOG.md 都能正确地生成,也算是成功地把 lerna 和 release-it 结合起来了!

遗留问题

踩过上面几个坑后,咱们总结出来的流程基本上能应付简单的 monorepo 使用场景,但是也并非说就没有问题了。我遇到的一个很高频的问题就是:由于创建 release 的过程需要多次与 github 交互,这就涉及到国内比较经典的网络问题,可能会出现 lerna version 成功了,但是 release-it 的某个步骤与 github 失联的情况。release-it 会在失败后执行一些回滚操作,而 lerna version 脚本是在钩子中被执行的,release-it 并不会回滚这部分自定义的脚本,这就会导致回滚不彻底。

不过这也是后话了,后面再说说怎么解决这个问题。

细节

在 debug 的过程中还学到了一些细节。

主版本号为0,BREAKING CHANGE 无效

当主版本号为 0 时,所有的变更都认为是不稳定的,此时即便是我们在 commit 信息中包含了 BREAKING CHANGE,lerna version 也不会为我们修改 major 版本号,具体请看下图:

image.png

the transition from 0.x to 1.x must be explicitly requested by the user.

Breaking changes MUST NOT automatically bump the major version from 0.x to 1.x.

所以,如果你遇到的问题符合上述情况,请不必怀疑自己,0.x 版本到 1.x 版本的变更必须由你自行操作,工具不负责这个场景。没有实践过还真不知道这个细节!

结语

通过本文的学习,我们不仅能掌握如何组织起经典的前端研发流程,还能认识到,优秀的工具也不是拍脑袋想出来的,一定是先有规范,再根据规范出上层工具,所以制定规范是一件很重要的事情。另外一点就是,不要局限于开源工具提供的能力,可以自己适当地去想办法优化或者改造,以达到自己的目的。

当然,文中所述流程不一定适合所有场景,仅供读者参考!

如果您对我的专栏感兴趣,欢迎您订阅关注本专栏,接下来可以一同探讨和交流组件库开发过程中遇到的问题。

技术交流&闲聊:程序员白彬

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant