前言
目前大部分的项目中都会通过husky去做提交的校验(husky 可以防止使用 Git hooks 的一些不好的 commit 或者 push。),本次尝试自己去实现类似于husky的git hooks工具
1.前置知识点
当我们通过npm/cnpm/yarn/pnpm 去运行命令的时候,会去找package.json的script字段中找到对应的命令,如下:
"scripts": {
"lint": "eslint --fix --ext .js src/",
"prettier": "prettier --write --parser typescript "src/**/*.ts"",
"vite": "vite --config build/vite/vite.config.js --force"
},
1.1 npm是如何运行的?
npm 脚本的原理非常简单。每当执行npm run,就会自动新建一个 Shell,在这个 Shell 里面执行指定的脚本命令。因此,只要是 Shell(一般是 Bash)可以运行的命令,就可以写在 npm 脚本里面。
比较特别的是,npm run新建的这个 Shell,会将当前目录的 node_modules/.bin 子目录加入 PATH 变量,执行结束后,再将 PATH 变量恢复原样。
这意味着,当前目录的node_modules/.bin子目录里面的所有脚本,都可以直接用脚本名调用,而不必加上路径。
"lint": "eslint --fix --ext .js src/"
// 不用写成下面这样
"lint": "./node_modules/.bin/eslint --fix --ext .js src/"
npm和yarn cnpm pnpm 等都是node的依赖管理器 他们都只是对 package.json 进行解析而已,npm run xxx的时候,首先会去项目的package.json文件里找scripts 里找对应的xxx,然后执行 xxx的命令(会先在当前 node_modules/.bin下面看有没有同名的可执行文件,如果有,则使用其运行。).bin下面的文件代表的是软连接(如何知道软连接在哪里执行:对应包文件package.json中的bin字段),在我们安装对应包的时候npm会帮我们配置好软连接,这 种软连接相当于一种映射 ,当我们运行对应的npm scripts命令的时候, 就会到 node_modules/bin中找对应的映射文件,然后再找到相应的js文件来执行。
举个栗子:
.bin目录
node_modules/vite 目录
1.2 git hooks 钩子的作用
Git Hooks是定制化的脚本程序,所以它实现的功能与相应的git动作相关,如下几个简单例子:
1.多人开发代码语法、规范强制统一
2.commit message 格式化、是否符合某种规范
3.如果有需要,测试用例的检测
4.服务器代码有新的更新的时候通知所有开发成员
5.代码提交后的项目自动打包(git receive之后) 等等...
更多的功能可以按照生产环境的需求写出来
举个栗子
"scripts": {
"prebuild":"",
"build": "",
"postbuild": ""
}
相当于
npm run prebuild&&npm run build&&npm run postbuild
因此可以在npm build 命令之前执行一些特殊的操作
钩子的情况
- applypatch-msg(应用程序消息)
- pre-applypatch(应用前批处理)
- post-applypatch(应用程序批处理后)
- pre-commit(预先提交)
- pre-merge-commit(合并前提交)
- prepare-commit-msg(准备提交消息)
- commit-msg(提交信息)
- post-commit(提交后)
- pre-rebase(变基前)
- post-checkout(结账后)
- post-merge(合并后)
- pre-push(预推)
- ....
如何去使用:
默认情况下,hooks目录是$GIT_DIR/hooks,但是可以通过core.hooksPath配置变量来更改
commit操作有 4个挂钩被用来处理提交的过程,他们的触发时间顺序如下: pre-commit、prepare-commit-msg、commit-msg、post-commit
2.实现一些功能
- 提交前对代码进行eslint校验
- 提交身份校验(本次以邮箱为例)
- 提交规范校验
- 代码文件命名规范校验
- 代码方法注释校验
2.1 实现提交eslint校验
在提交之前对代码进行eslint校验
安装eslint对应包
yarn add eslint eslint-plugin-prettier -D
配置package.json scripts
"scripts": {
"lint": "eslint --fix --ext .js src/",
"postinstall": "git config core.hooksPath hooks && chmod 700 hooks/*"
},
在项目内新增hooks文件夹,在hooks文件夹中新建pre-commit文件
2.1.1 代码
#!/usr/bin/env node
const childProcess = require('child_process');
const chalk = require('chalk');
try {
childProcess.execSync('npm run lint');
} catch (error) {
console.log(chalk.red(error.stdout.toString()));
process.exit(1);
}
此刻当我们运行git commit提交时,会进行eslint校验
2.1.2 举个栗子
/**
* 加法
* @param {*} a
* @param {*} b
* @returns
*/
function add(a, b) {
return a + b;
}
/**
* 减法
* @param {*} a
* @param {*} b
* @returns
*/
function reduce(a, b) {
return a - b;
}
add(1, 2);
此时会报错
2.2 实现提交邮箱校验
对提交用户进行身份校验,避免非仓库用户能够进行提交
2.2.1 代码
#!/usr/bin/env node
const testEmail = /xx.com$/;
//公司邮箱才能进行提交代码
const path = require('path');
const chalk = require('chalk');
/**
* 校验邮箱
* @param {*} testEmail
*/
const checkEmail = (testEmail) => {
const email = childProcess.execSync('git config user.email').toString().trim();
if (!testEmail.test(email)) {
console.log(chalk.red('此用户没有权限,具有权限的用户为:xx.com'));
process.exit(1);
}
};
checkEmail(testEmail)
2.2.2 举个栗子
2.3 实现提交规范
实现husky的提交规范
2.3.1代码
#!/usr/bin/env node
const testEmail = /xx.com$/;
//公司邮箱才能进行提交代码
const path = require('path');
const chalk = require('chalk');
const childProcess = require('child_process');
const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)((.+))?: .{1,100}/;
const msg = fs.readFileSync(process.argv[2], 'utf-8').trim(); // 索引 2 对应的 commit 消息文件
/**
* 校验提交规范
*/
const checkCommit = () => {
// 校验提交规范
if (!commitRE.test(msg)) {
console.log(chalk.yellow(
'不合法的 commit 消息格式,请使用正确的提交格式:\n' +
' fix: handle events on blur (close #28):\n' +
' 详情请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md'
));
process.exit(1);
}
};
checkCommit()
2.3.2 举个栗子
2.4 实现文件命名规范校验
kebab-case命名规范,通过是否含大写字母为依据
2.4.1 代码
const chalk = require('chalk');
const fs = require('fs');
const path = require('path');
/**
* 校验提交文件目录命名规范
* @param {*} folder
*/
const checkFileCase = async (folder) => {
await fs.readdir(folder, async (_err, files) => {
if (_err) {
console.warn(_err);
process.exit(1);
}
// 遍历读取到的文件列表
files.forEach(filename => {
// 获取当前文件的绝对路径
const filedir = path.join(folder, filename);
// 根据文件路径获取文件信息,返回一个fs.Stats对象
fs.stat(filedir, async (err, stats) => {
if (err) {
console.warn(err);
process.exit(1);
} else {
const isDir = stats.isDirectory(); // 是文件夹
if (isDir) {
checkFileCase(folder + '/' + filename);
} else {
if (/[A-Z]/.test(filename)) {
// 目前只校验是否含大写字母
console.log(chalk.yellow(folder + '/' + filename + ' 文件请遵守kebab-case命名规范'));
process.exit(1);
}
}
}
});
});
});
};
checkFileCase(path.resolve() + '/src')
2.4.2 举个栗子
存在一个组件目录
提交后报错
2.5 实现方法注释校验
开发过程中经常有存在方法无注释的情况,通过添加git提交校验,强制开发人员对于方法注释的书写和维护
涉及以下两种书写规范
- function
- 剪头函数
2.5.1 以vue3为例
.vue文件中
<template>
<div></div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
name: 'App',
setup() {
const show = ref<boolean>(false);
/**
* 关闭弹窗
*/
const closePopup = () => {
show.value = false;
};
const openPopup = () => {
show.value = true;
};
return {
show,
closePopup,
openPopup
};
}
});
</script>
<style lang="less"></style>
工具函数中:
/**
* 判断是否为空
* @param {*} input
* @returns
*/
const isEmpty = (input) => {
return input == null || input == '';
};
const isBlank = (input) => {
return input == null || /^\s*$/.test(input);
};
/**
* 判断是空或者undefined
* @param {*} input
* @returns
*/
const isEmptyOrUndefined = (input) => {
return isEmpty(input) || typeof input == 'undefined' || input == 'defined';
};
/**
* 参数为空返回"",否则返回去空格的参数
*/
const trimToEmpty = (input) => {
return isEmptyOrUndefined(input) ? '' : input.trim();
};
/** 只包含字母 **/
const isAlpha = (input) => {
return /^[a-z]+$/i.test(input);
};
module.exports = {
isAlpha,
isBlank,
isEmpty,
isEmptyOrUndefined,
trimToEmpty
};
也会存在以function命名的
/**
* 加法
* @param {*} a
* @param {*} b
* @returns
*/
function add(a, b) {
return a + b;
}
function reduce(a, b) {
return a - b;
}
add(1, 2);
reduce(2, 1);
2.5.2 代码
这个时候就需要babel来帮助我们分析代码了,但是对于.vue的文件 @babel/parser是无法解析成ast的,所以需要通过 @vue/compiler-sfc(Vue3) 先提取出script部分的代码,然后在通过@babel/parser进行解析
vue2版本通过vue-template-compiler 编译,版本需要与vue的版本对应一致
引入相关babel包,解析出ast结构,可通过astexplorer.net/网站对应分析
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// const vueCompiler = require('vue-template-compiler') //VUE2版本
const vueCompiler = require('@vue/compiler-sfc'); // VUE3版本
const isVue = fileDir.search('.vue') > -1;
const sourceCode = fs.readFileSync(fileDir, 'utf-8');
let ast = '';
if (isVue) {
// // vueStr .vue 文件内容 VUE2版本
// const vueCode = vueCompiler.parseComponent(sourceCode);
// ast = parser.parse(vueCode.script.content, {
// allowImportExportEverywhere: true
// });
// 提取vueCode
const vueCode = vueCompiler.parse(sourceCode);
if (vueCode.descriptor.script) {
// const { code } = vueCompiler.compileTemplate({
// id: fileDir,
// filename: fileDir,
// source: vueCode.descriptor.script.content
// });
ast = parser.parse(vueCode.descriptor.script.content, {
sourceType: 'unambiguous'
});
}
} else {
ast = parser.parse(sourceCode, {
sourceType: 'unambiguous'
});
对应ast 结构,两种方法对应分别是VarDeclaration/ArrowFunctionExpression和FunctionDeclaration
其中const命名的方法对应的ast是
function命名的方法对应的ast是
leadingComments字段代表的是前面的注释,trailingComments后面的注释,所以我们可以通过去查找对应节点leadingComments数组内type类似是CommentBlock 的对象是否存在,来判断是否存在注释,也可以提取value值然后生成方法的文档
traverse(ast, {
FunctionDeclaration: {
enter(path, state) {
checkLeadingComments(path.node, fileDir, path.node.id.name);
}
},
VariableDeclaration: {
enter(path, state) {
const _declarations = path.node.declarations[0];
if (_declarations.init.type === 'ArrowFunctionExpression') {
checkLeadingComments(path.node, fileDir, _declarations.id.name);
}
}
}
});
if (_errStats.length) {
console.error(`${_errStats.join('\n')}`);
process.exit(1);
}
2.5.3 举个栗子
3.完整check代码
const chalk = require('chalk');
const fs = require('fs');
const path = require('path');
const childProcess = require('child_process');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// const vueCompiler = require('vue-template-compiler') //VUE2版本
const vueCompiler = require('@vue/compiler-sfc'); // VUE3版本
const commitRE = /^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)((.+))?: .{1,100}/;
const email = childProcess.execSync('git config user.email').toString().trim();
const msg = fs.readFileSync(process.argv[2], 'utf-8').trim(); // 索引 2 对应的 commit 消息文件
const _errStats = [];
/**
* 校验提交规范
*/
const checkCommit = () => {
if (!commitRE.test(msg)) {
console.log(chalk.yellow(
'不合法的 commit 消息格式,请使用正确的提交格式:\n' +
' fix: handle events on blur (close #28):\n' +
' 详情请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md'
));
process.exit(1);
}
};
/**
* 校验邮箱规范
* @param {*} testEmail
*/
const checkEmail = (testEmail) => {
if (!testEmail.test(email)) {
console.log(chalk.red('此用户没有权限,具有权限的用户为: xxx@qq.com或者100.com'));
process.exit(1);
}
};
/**
* 校验提交文件目录命名规范
* @param {*} folder
*/
const checkFileCase = async (folder) => {
checkFileStats(folder, false, (filename) => {
if (/[A-Z]/.test(filename)) {
// 目前只校验是否含大写字母
console.log(chalk.yellow(folder + '/' + filename + ' 文件请遵守kebab-case命名规范'));
process.exit(1);
}
});
};
/**
* 检查提交文件内方法有没有写注释
* @param {*} folder
*/
const checkFileExplanatory = async (folder) => {
checkFileStats(folder, true, (fileDir) => {
const isVue = fileDir.search('.vue') > -1;
const sourceCode = fs.readFileSync(fileDir, 'utf-8');
let ast = '';
if (isVue) {
// // vueStr .vue 文件内容 VUE2版本
// const vueCode = vueCompiler.parseComponent(sourceCode);
// ast = parser.parse(vueCode.script.content, {
// allowImportExportEverywhere: true
// });
// 提取vueCode
const vueCode = vueCompiler.parse(sourceCode);
if (vueCode.descriptor.script) {
// const { code } = vueCompiler.compileTemplate({
// id: fileDir,
// filename: fileDir,
// source: vueCode.descriptor.script.content
// });
ast = parser.parse(vueCode.descriptor.script.content, {
sourceType: 'unambiguous'
});
}
} else {
ast = parser.parse(sourceCode, {
sourceType: 'unambiguous'
});
}
traverse(ast, {
FunctionDeclaration: {
enter(path, state) {
checkLeadingComments(path.node, fileDir, path.node.id.name);
}
},
VariableDeclaration: {
enter(path, state) {
const _declarations = path.node.declarations[0];
if (_declarations.init.type === 'ArrowFunctionExpression') {
checkLeadingComments(path.node, fileDir, _declarations.id.name);
}
}
}
});
if (_errStats.length) {
console.error(`${_errStats.join('\n')}`);
process.exit(1);
}
});
};
/**
*
* @param {*} node ast path.node 节点
* @param {*} fileDir 文件路劲
* @param {*} name 方法名
*/
const checkLeadingComments = async (node, fileDir, name) => {
const _leadingComments = node.leadingComments;
// eslint-disable-next-line no-undef
const hasCommentBlock = _leadingComments?.find(item => item.type === 'CommentBlock');
if (!_leadingComments || !hasCommentBlock) {
_errStats.push(`X: ${fileDir}中的${name}方法未添加注释!!`);
}
};
/**
* 文件状态校验
* @param {*} folder 文件目录
* @param {*} isFilePath 是否是文件路劲
* @param {*} checkCallBack 回调方法
*/
const checkFileStats = async (folder, isFilePath, checkCallBack) => {
await fs.readdir(folder, async (_err, files) => {
if (_err) {
console.warn(_err);
process.exit(1);
}
// 遍历读取到的文件列表
files.forEach(filename => {
// 获取当前文件的绝对路径
const fileDir = path.join(folder, filename);
fs.stat(fileDir, async (err, stats) => {
if (err) {
console.warn(err);
process.exit(1);
} else {
const isDir = stats.isDirectory(); // 是文件夹
if (isDir) {
checkFileStats(folder + '/' + filename, isFilePath, checkCallBack);
} else {
checkCallBack(isFilePath ? fileDir : filename);
}
}
});
}
);
});
};
module.exports = {
checkCommit,
checkEmail,
checkFileCase,
checkFileExplanatory
};
4.shell代码来一波
4.1一些知识点
4.1.1 # /bin/sh和# /bin/bash的区别
#/bin/sh 相当于 /bin/bash --posix # sh跟bash的区别,实际上就是bash有没有开启posix模式的区别。so,可以预想的是, # 如果第一行写成 #!/bin/bash --posix,那么脚本执行效果跟#!/bin/sh是一样的(遵循posix的特定规范,有可能就包括这样的规范:“当某行代码出错时,不继续往下解释”)
4.1.2 退出码
Shell脚本有三种方式返回退出码:
- exit命令,用exit返回退出码,exit后的指令不会再执行。
- return命令,但是要注意的是只有用source方式调用的脚本中才能用return返回退出码,return后的指令同样不会再执行。
- 如果脚本中既没有exit也没有return,则脚本的退出码为脚本中最后一条指令的退出码。
在脚本或函数执行完成后,紧接着通过$?就可以获得脚本或函数的退出码。
4.1.3 其他
- “=~”正则匹配,用来判断其左侧的参数是否符合右边的要求,如果匹配则为真(返回1)
- 逻辑与的表达:if [ xx=b ] -a表示and的意思 / if [ xxx=a ] && [ xx=b ]
- 逻辑或的表达: if [ xx=b ] -o表示or的意思 / if [ xx=b ]
4.2 完成提交前eslint校验
pre-commit 代码如下
#!/bin/bash
# 执行 lint 脚本,如果不正确需要将退出码设为非零
npm run lint
# 在脚本或函数执行完成后,紧接着通过$?就可以获得脚本或函数的退出码。
exitCode="$?"
exit $exitCode
4.3 提交时提交规范以及邮箱校验
commit-msg代码如下
#!/bin/bash
# 获取当前提交的git commit -m '' $1获取输入的值
commit_msg=`cat $1`
# 获取用户 email
email=`git config user.email`
commit_re="^(feat|fix|docs|style|refactor|perf|test|workflow|build|ci|chore|release|workflow)((.+))?: .{1,100}"
# “=~”正则匹配,用来判断其左侧的参数是否符合右边的要求,如果匹配则为真(返回1)
# ,不匹配则为假(返回0)
if [[ ! $commit_msg =~ $commit_re ]]
then
echo "不合法的 commit 消息提交格式,请使用正确的格式:"
echo "详情请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md"
# 异常退出
exit 1
fi
email_re="100.com"
# 逻辑与的表达:if [ $xxx=a -a $xx=b ] -a表示and的意思 / if [ $xxx=a ] && [ $xx=b ]
# 逻辑或的表达: if [ $xxx=a -o $xx=b ] -o表示or的意思 / if [ $xxx=a ] || [ $xx=b ]
if [[ ! $email =~ $email_re ]]
then
echo "此用户没有权限,具有权限的用户为: 100.com"
# 异常退出
exit 1
fi