背景
最近在研究代码 Lint
相关的内容,业界比较常用的方案是Husky
配合lint-staged
在代码提交前进行Lint,防止将不规范的代码提交到远端。
对Husky
的工作原理很感兴趣,花了点时间研究,借此文做一下总结,希望对正在学习这块内容的朋友有一些帮助。
Lint最佳实践
说「最佳实践」可能有点不恰当,但我见过的大多数前端项目都是采用这种组合
以Javascript为例,要进行代码Lint,主要有以下步骤:
- 安装相应的包,包括:
eslint
、husky
和lint-staged
,如何安装不是本文重点,请自行学习 - 添加相应配置:
- 增加
.eslintrc.js
文件,配置eslint
,具体配置根据项目和团队需求自行配置,可参见eslint文档 - 在
package.json
或者.huskyrc
文件中增加 husky 配置项
"husky": { "hooks": { "pre-commit": "lint-staged" } },
- 在package.json中增加 lint-staged 配置项
"lint-staged": { "**/*.js": "eslint" },
- 增加
到此,git commit时就会进行代码校验,并且只会校验staged的文件。
ESlint
和lint-staged
不是本文的重点,请自行学习,本文重点关注Husky
的原理
Husky的原理
git hooks介绍
Husky是如何在代码提交时触发代码校验的?在研究它的原理之前,需要介绍另外一个概念:git hooks
,官方文档的描述是:
和其它版本控制系统一样,Git 能在特定的重要动作发生时触发自定义脚本。 有两组这样的钩子:客户端的和服务器端的。 客户端钩子由诸如提交和合并这样的操作所调用,而服务器端钩子作用于诸如接收被推送的提交这样的联网操作。 你可以随心所欲地运用这些钩子。
目前git支持17个hooks,都以单独的脚本形式存储在.git/hooks文件夹下:
以一次commit为例,会先后触发pre-commit
、prepare-commit-msg
、commit-msg
和post-commit
等hooks。我们可以利用这些hooks做一些有趣的事。比如:我们可以利用pre-commit
进行代码校验,利用commit-msg
进行commit message的校验,只要你懂得shell语法,当然你也可以使用Perl、Ruby或者Python。
另外,并不需要我们创建所有hook脚本,只需要按需创建即可。
Husky和git hooks的关系
Husky
官方的描述是:
Git hooks made easy(让git hooks变得简单)
想象一个场景,比如在一个多人协作的团队,你在.git/hooks
中创建了一些hooks,你希望共享给队友,但.git/hooks
文件夹并不会提交到远端,无奈只能拷贝。
Husky
就是为了解决这个问题而生的,只需要简单的配置,就可以完成hook的工作,具体配置方法参见Husky
使用文档,以package.json
为例:
// package.json
"husky": {
"hooks": {
"pre-commit": "eslint"
}
},
在npm安装Husky
时,Husky
会在项目的.git/hooks
文件夹下创建所有支持的hooks,另外还会创建 husky.local.sh
和husky.sh
两个文件。其实每个hook脚本的内容都一样:
# pre-commit
#!/bin/sh
# husky
# Created by Husky v4.2.5 (https://github.com/typicode/husky#readme)
# At: 2020/8/3 上午11:25:21
# From: ...(https://github.com/typicode/husky#readme)
. "$(dirname "$0")/husky.sh"
我们可以看到仅仅是执行husky.sh
脚本,重点在husky.sh脚本中。
Husky安装hooks的原理
Husky是如何做到在安装后创建hooks中的文件的? 其实是用了npm scripts的install指令,当npm包在安装完毕后会自动执行该指令下的脚本,具体可参加文档。 通过查看Husky的package.json可知,指令为:
node husky install
最终执行的是 ./lib/installer/bin
中的脚本,而 hooks 的创建逻辑在 ./lib/installer/hooks.js
中,有兴趣的同学可以去看下源码。
husky核心源码解读
Husky的核心代码都在husky.sh文件中:
# Created by Husky v4.2.5 (https://github.com/typicode/husky#readme)
# At: 2020/8/3 上午11:25:21
# From: ... (https://github.com/typicode/husky#readme)
debug () {
if [ "$HUSKY_DEBUG" = "true" ] || [ "$HUSKY_DEBUG" = "1" ]; then
echo "husky:debug $1"
fi
}
command_exists () {
command -v "$1" >/dev/null 2>&1
}
run_command () {
if command_exists "$1"; then
"$@" husky-run $hookName "$gitParams"
exitCode="$?"
debug "$* husky-run exited with $exitCode exit code"
if [ $exitCode -eq 127 ]; then
echo "Can't find Husky, skipping $hookName hook"
echo "You can reinstall it using 'npm install husky --save-dev' or delete this hook"
else
exit $exitCode
fi
else
echo "Can't find $1 in PATH: $PATH"
echo "Skipping $hookName hook"
exit 0
fi
}
hookIsDefined () {
grep -qs $hookName \
package.json \
.huskyrc \
.huskyrc.json \
.huskyrc.yaml \
.huskyrc.yml
}
huskyVersion="4.2.5"
gitParams="$*"
hookName="$(basename "$0")"
debug "husky v$huskyVersion - $hookName"
# Skip if HUSKY_SKIP_HOOKS is set
if [ "$HUSKY_SKIP_HOOKS" = "true" ] || [ "$HUSKY_SKIP_HOOKS" = "1" ]; then
debug "HUSKY_SKIP_HOOKS is set to $HUSKY_SKIP_HOOKS, skipping hook"
exit 0
fi
# Source user var and change directory
. "$(dirname "$0")/husky.local.sh"
debug "Current working directory is $(pwd)"
# Skip fast if hookName is not defined
# Don't skip if .huskyrc.js or .huskyrc.config.js are used as the heuristic could
# fail due to the dynamic aspect of JS. For example:
# `"pre-" + "commit"` or `require('./config/hooks')`)
if [ ! -f .huskyrc.js ] && [ ! -f husky.config.js ] && ! hookIsDefined; then
debug "$hookName config not found, skipping hook"
exit 0
fi
# Source user ~/.huskyrc
if [ -f ~/.huskyrc ]; then
debug "source ~/.huskyrc"
. ~/.huskyrc
fi
# Set HUSKY_GIT_STDIN from stdin
case $hookName in
"pre-push"|"post-rewrite")
export HUSKY_GIT_STDIN="$(cat)";;
esac
# Windows 10, Git Bash and Yarn 1 installer
if command_exists winpty && test -t 1; then
exec < /dev/tty
fi
# Run husky-run with the package manager used to install Husky
case $packageManager in
"npm") run_command npx --no-install;;
"npminstall") run_command npx --no-install;;
"pnpm") run_command pnpx --no-install;;
"yarn") run_command yarn run --silent;;
*) echo "Unknown package manager: $packageManager"; exit 0;;
esac
我们提取其中几个关键点来进行分析:
一、第一个关键点是通过 basename "$0" 获取当前脚本的名称,比如: pre-commit
,这一点很重要,后面的指令匹配都是围绕这个名称,后面内容中的 hookName 都以 pre-commit 为例
hookName="$(basename "$0")"
二、带二个关键点是 hookIsDefined 函数,它的原理就是通过grep
指令判断各个配置文件中是否存在 pre-commit,
hookIsDefined () {
grep -qs $hookName \
package.json \
.huskyrc \
.huskyrc.json \
.huskyrc.yaml \
.huskyrc.yml
}
第三个关键点是 run_command 函数,作用就是用本地的husky-run指令执行hook,按照文章开头的配置,就是执行eslint
。
npx --no-install husky-run pre-commit "$gitParams"
husky-run对应的执行脚本是.node_modules/husky/bin/run.js
,脚本内容也很简单,就是调用了.node_modules/husky/lib/runner/bin.js
,而最终是调用了 .node_modules/husky/lib/runner/index.js
中的runCommand接口,在接口中起了子进程执行pre-commit中对应的脚本。
Husky原理总结
到此Husky
的原理介绍完毕,我们进行一下总结:
- 安装时创建hooks
- 提交时从配置文件中(
package.json、.huskyrc、.huskyrc.json...
)读取相应的 hook 配置 - 执行配置中的指令/脚本