Husky原理解析及在代码Lint中的应用

5,406 阅读4分钟

背景

最近在研究代码 Lint 相关的内容,业界比较常用的方案是Husky配合lint-staged在代码提交前进行Lint,防止将不规范的代码提交到远端。

Husky的工作原理很感兴趣,花了点时间研究,借此文做一下总结,希望对正在学习这块内容的朋友有一些帮助。

Lint最佳实践

说「最佳实践」可能有点不恰当,但我见过的大多数前端项目都是采用这种组合

以Javascript为例,要进行代码Lint,主要有以下步骤:

  1. 安装相应的包,包括:eslinthuskylint-staged,如何安装不是本文重点,请自行学习
  2. 添加相应配置:
    1. 增加.eslintrc.js文件,配置eslint,具体配置根据项目和团队需求自行配置,可参见eslint文档
    2. package.json或者.huskyrc文件中增加 husky 配置项
    "husky": {
      "hooks": {
        "pre-commit": "lint-staged"
      }
    },
    
    1. 在package.json中增加 lint-staged 配置项
    "lint-staged": {
      "**/*.js": "eslint"
    },
    

到此,git commit时就会进行代码校验,并且只会校验staged的文件。

ESlintlint-staged不是本文的重点,请自行学习,本文重点关注Husky的原理

Husky的原理

git hooks介绍

Husky是如何在代码提交时触发代码校验的?在研究它的原理之前,需要介绍另外一个概念:git hooks,官方文档的描述是:

和其它版本控制系统一样,Git 能在特定的重要动作发生时触发自定义脚本。 有两组这样的钩子:客户端的和服务器端的。 客户端钩子由诸如提交和合并这样的操作所调用,而服务器端钩子作用于诸如接收被推送的提交这样的联网操作。 你可以随心所欲地运用这些钩子。

目前git支持17个hooks,都以单独的脚本形式存储在.git/hooks文件夹下:

以一次commit为例,会先后触发pre-commitprepare-commit-msgcommit-msgpost-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.shhusky.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的原理介绍完毕,我们进行一下总结:

  1. 安装时创建hooks
  2. 提交时从配置文件中(package.json、.huskyrc、.huskyrc.json...)读取相应的 hook 配置
  3. 执行配置中的指令/脚本