你知道怎么组织和优化前端研发流程吗?
专栏上篇文章传送门:在本地和CI/CD中支持npm免登录发布
本节涉及的内容源码可在vue-pro-components c9 分支找到,欢迎 star 支持!
前面几篇都在说函数库开发的相关内容,所以本文接着围绕这块说,主要是把研发流程梳理清楚,方便后续更多内容的铺开。
梳理研发流程
我们先粗略整理一下函数库的主要研发流程。
- 写代码,不限于需求/缺陷/优化等内容。
- 做一次 commit。
- 修改版本号。
- 生成 changelog。
- 本地打包发布 + 提交到 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 信息,就有机会推导出下一个版本号。
按照上图中提供的信息,我们可以知道,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-style
和lint-style-fix
同理。
我们期望在提交代码前进行代码质量校验,这需要用到 git hooks 中的 pre-commit 钩子,在 pre-commit 钩子中可以执行 eslint 等 lint 命令。
husky 对 git hooks 进行了良好的封装,我们根据指引安装一下。
// 由于我们当前使用的是 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
所以,完整的逻辑是:
规范 commit
首先安装一下 commitizen 及相关依赖:
yarn add -DW commitizen cz-conventional-changelog
然后在package.json
中加入以下配置:
"config": {
"commitizen": {
"path": "cz-conventional-changelog"
}
}
接着就可以正常使用git cz
命令了。
但是,即便引入了 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 就是无法通过的。
回顾流程
我们再来回顾和梳理一下流程:
- 开发代码
- git cz 交互式 commit
- husky + pre-commit + lint-staged 进行必要的 linter 校验
- husky + commit-msg + commitlint 进行 commit 校验
- 通过 lerna version 进行 version bump,并生成 changelog 和 github release,最后 push 到 github。
- 在 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/utils@x.x.x
, @vue-pro-components/headless@x.x.x
, vue-pro-components@x.x.x
。
其实创建三个 release 也没啥问题,因为我采用的是 lerna 的 Independent Mode,各个子包版本号独立;但是考虑到我的 github actions 的触发条件是release 的创建,这也意味着三个 release 会触发三次 github actions workflow,虽然三次 workflow 执行完的结果是一样的,但是完全没必要做重复的工作,按需使用是我们的宗旨。
目前,lerna version 这个行为还没有参数可以用来控制开关,但是并不是说这是 lerna 的问题,也许我们可以改进自己的流程来规避这个问题。
改进流程
idea 1
接上面,我的第一个想法是,在不同的 release 对应的 workflow 中取出 release name,release name 中有包名的信息,自然就可以基于此按需打包发布。
但是,这也存在一个问题,实际上,包之间是有依赖关系的,也就意味着在某些工序上可能有先后顺序。
如果所有子包都各自独立打包,其实是有问题的,比如当多个 release 对应的 workflow 同时进行时,如果包 A 依赖的某个包 B 还没打包并发布到 npm registry,就有可能导致 A 打包出错。
所以最好的办法还是按依赖关系决定的顺序,放在一起打包发布。
idea 2
我的第二个想法是:执行 lerna version 的时候不要创建 release,也就是不带--create-release
参数。接着再通过其他脚本或工具给整个工程打个 tag 和 release。这样一来,一次发布过程就只会产生一个 release,因此也只会执行一次 github actions workflow,看起来还比较符合我的心意。
我们考虑引入 release-it,用它来重新组织流程。
我们这里用到了一个插件 @release-it/conventional-changelog,它很重要。
我们再理一遍流程:
- 首先还是写代码。
- 接着通过 git cz 做一次 commit。
- 经过必要的钩子检查。
- 开始执行 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 文件。
- 接着 release-it 根据 git log 确定一份 changelog 信息,用于辅助后续过程。
- 由于插件 @release-it/conventional-changelog 实现了
getIncrementedVersionCI
方法,所以可以决定下一个版本号,具体到内部逻辑,其核心是用到了 conventional-recommended-bump 这个包,它能基于 conventional commits 规范给出建议的 releaseType(对应 major, minor, patch 等),再结合 semver.inc,就能得到下个版本号。
- 接着就是执行 release-it 插件的各个钩子,以及收尾的
release
和afterRelease
钩子。其中核心插件 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 中,但是结果并没有。
我发现这是因为 lerna version 虽然去掉了--create-release
参数,没有再创建 release,但是 tag 还是打出来了。这就会导致 release-it 在对比 0.2.0
和 @vue-pro-components/headless@0.2.4
两个 tag 的差异时,找出的 commits 只是 chore 类型的 release 说明,比如:
chore: release v0.2.0
这就不足以体现到 CHANGELOG.md 中的,这与 Conventional Changelog Configuration Spec 有关。
所以要想办法去掉 lerna version 创建 tag 的行为。
我查了一下 lerna version 的文档,发现有一个参数--no-git-tag-version
看起来比较贴合我的需求,用了一下发现,它的行为是既不提交 commit,也不打 tag。而不做 commit 就会导致 git 工作区不是 clean 状态,这会导致后续的 release-it 流程无法继续。release-it 也有个配置项git.requireCleanWorkingDir
可以关闭 git 工作区 clean 的检查,不过我暂时不打算这么做。
我的思路是:由于我的目的还是去掉 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 的生成也变得正常,基本符合我的需求。
遗留问题
采用上面这种方式优化流程后,基本上能应付简单的 monorepo 使用场景,但是也并非说就没有问题了。我遇到的一个很高频的问题就是:由于创建 release 的过程需要多次与 github 交互,这就涉及到国内比较经典的网络问题,可能会出现 lerna version 成功了,但是 release-it 的某个步骤与 github 失联的情况。release-it 会在失败后执行一些回滚操作,而 lerna version 脚本是在钩子中被执行的,release-it 并不会回滚这部分自定义的脚本,这就会导致回滚不彻底。
不过这也是后话了,后面再说说怎么解决这个问题。
结语
通过本文的学习,我们不仅能掌握如何组织起经典的前端研发流程,还能认识到,优秀的工具也不是拍脑袋想出来的,一定是先有规范,再根据规范出上层工具,所以制定规范是一件很重要的事情。另外一点就是,不要局限于开源工具提供的能力,可以自己适当地去想办法优化或者改造,以达到自己的目的。
当然,文中所述流程不一定适合所有场景,仅供读者参考!
如果您对我的专栏感兴趣,欢迎您订阅关注本专栏,接下来可以一同探讨和交流组件库开发过程中遇到的问题。
技术交流&闲聊:前端司南
转载自:https://juejin.cn/post/7186458338392014906