背景
最近项目遇到一个问题是:部门用的是 Vue2+TS 项目,Type类型自动同步于接口文档,那么容易出现部署到测试环境失败的问题。原因为后端改动接口 + 自身编码时忽略,导致 TS 的类型变更没及时修正。因此同事好奇为啥已有的 pre-lint 没有在 commit 阶段把这些问题给拦截下来,大大滴影响了测试速率。
注意: 本文为本成品,只提供思路,需要读者根据自有项目进行修改,暂未做到通用化
2021.03.25 更新
- 单独 Lint TS的脚本无需开发(vue-type-check 底层调用的 Typescript Language Server API),使用 onlyTypeScript 命令即可(命名容易误导)
- vue-type-check 基于 TS 3.9+ ,tslib 1.13+ ,请根据自身项目调整,比如笔者会强行升到 tslib 2.1.0
为什么 Lint 失败
原因可能已经有很多人知道了,就不在详细说:详细原因。
简单的来说:官方认为逻辑处理是 TSC 做的事,且 TSC 已经做了,Eslint 只关心语法层面合理(即使是错误的语法)。
- 逻辑错误如:
- const test:number = '123456.js' // 逻辑错误,但是语法是合理的
- test.forEach( ... ) // test 是个字符串,执行的是 array 的api,逻辑错误,但语法是合理的
- 语法规则由执行设置的 eslint 规则决定:
- let a = {} // a 后面没进行过赋值,语法可能会认为用 const 更好
- i++ / ++i // 语法可能会认为 ++ 运算符不合理,用 += 1 会更好
关于这点,我也比较赞同这个观点:
- lint类工具设计就是用来检查代码格式(美观度)的,而不是检查逻辑的
- 如果TSlint自己做代码检查,就要重造tsc的轮子
- 如果TSlint调用tsc,还不如让用户自己去调用
一千个读者就有一千个哈姆雷特,各人有各人的看法,合理讨论,不要撕逼~
怎么解决
官方已经提示,用 TSC 代替 Lint 来做此步开发功能,我们要做的就是在 pre-commit 的时候去调用其即可。
经过一天的踩坑,我这边选择了用 Node 脚本来做这个工作,人生苦短,放弃 Shell。
创建执行 Shell 函数
Node 调用 Shell 的文章已经很多了,不再过多介绍,直接show code
const execute = (cmd, needSync = false) => {
const main = (error, stdout) => {
if (error) {
return error;
}
return stdout;
};
if (needSync) {
return execSync(cmd, main);
}
return exec(cmd, main);
};
// 调用示例
const childProcess = execute( 'shell 命令' );
childProcess.stdout.on('data', (data) => {
console.log(data);
});
childProcess.stderr.on('data', (data) => {
console.log(`tsc调用出错,错误原因为: ${data}`);
process.exitCode = 1; // process 是全局变量,此处用于控制退出码
});
childProcess.on('close', (code) => {
if (code !== 0) {
process.exitCode = code;
}
});
收集 git 提交的文件
此步骤收集提交的文件,我们只做增量校验
const getDiffFile = (cb = () => {}) => {
const childProcess = execute('git diff --name-only --cached');
childProcess.stdout.on('data', cb); // 执行回调
childProcess.stderr.on('data', (data) => {
console.log(`git diff命令出错,错误原因为: ${data}`);
process.exitCode = 1;
});
};
使用 TSC 校验
查阅文档可知,tsconfig.json 有 files、 include 参数控制校验的文件,因此我们只需要改动下这两处配置即可
由于 TS 用的不熟,暂时没有找到 TSC 指定配置的指令,且没有找到文件的指令(只找到了个 -p 指点的是目录。。)
因此采用比较 low 的方案是,先备份配置文件,校验完成后再恢复 & 删除备份。因此我们需要一个备份函数。
const copyFileSync = (from, to) => {
const content = fs.readFileSync(from);
fs.writeFileSync(to, content);
};
Node 有很多种读写文件的方式,这里采用这种的原因是,能保证内容的格式不会被打乱,以至于产生 diff。 下面是正式调用代码:
const lintTsc = () => {
const tsc = path.resolve(__dirname, 'node_modules/.bin/tsc');
const tsCheck = () => {
const childProcess = execute(`${tsc} --noEmit`); // 只编辑,但是不输出编译后的结果
childProcess.stdout.on('data', (data) => {
console.log(data);
});
childProcess.stderr.on('data', (data) => {
console.log(`tsc调用出错,错误原因为: ${data}`);
process.exitCode = 1;
});
childProcess.on('close', (code) => {
// 还原配置 & 删除冗余备份
copyFileSync('./tsconfig_cp.json', './tsconfig.json');
fs.unlinkSync('./tsconfig_cp.json');
if (code !== 0) {
process.exitCode = code;
}
});
};
const validTsFile = (res) => {
let result = [];
try {
result = res.split(/\n/);
const files = result.filter((p) => /.tsx?$/.test(p)); // 从 commit 的文件中只过滤 ts 文件
copyFileSync('./tsconfig.json', './tsconfig_cp.json'); // 备份配置
tsconfig.include = ['src/type/*.ts', '全局 TS-Type 的目录']; // 此处逻辑跟项目有关
tsconfig.files = files;
fs.writeFileSync(path.resolve(__dirname, './tsconfig.json'), JSON.stringify(tsconfig));
tsCheck();
} catch (e) {
console.log(e);
process.exitCode = 1;
}
};
getDiffFile(validTsFile);
};
tsconfig.include 那一行跟项目有关,不能少了自己项目的全局 Type 文件,否则在校验时,会把注册过的全局 Type 当成错误提示。像笔者的项目,此处就还得加上同步过来的后端结果 Type。
写到这,你可以先执行 commit ts 类型文件后,执行 Node lint.js(假设起的文件名叫这个)
来验证一下 lint ts 类型的文件是否成功了。
校验 Vue 文件
用过 Vscode 写 Vue的同学一定装过 Vetur 这个插件,你会发现该插件就能识别 Vue 文件中的代码逻辑错误,此处我们借用的是第三方库跟他有比较大的关系。目前笔者调研到的两个能用的第三方库如下:
工具名 | 优势 | 不足 | 介绍 |
---|---|---|---|
VTI | 1 Vetur 官方维护,质量可以保证。 2 可以校验 vue 文件中的 ts 类型推断 | 1 暂时无更新维护消息 2 无法校验 vue文件中的 script 片段中内容 3 无法指定校验文件/目录,即无法做增量校验 | vuejs.github.io/vetur/guide… |
vue-type-check | 1 官网 Demo 可以校验 vue 文件中的 script 片段中内容 2 可以指定校验目录(不能指定文件) | 1 个人维护项目,且最新维护时间为 3 个月前 2 亲测也有问题,无法校验到 vue 文件中的 script 片段中内容 | github.com/Yuyz0112/vu… |
此处笔者选用的是 vue-type-check 插件,原因为两个:
- 支持增量校验
- fork 的版本有可以校验 script 的版本,可以 down 下来,后期发到公司的包管理站上,自行维护
使用 vue-type-check 的一个比较大的问题是,无法指定到文件,仅支持到目录级别,因此这里暂时采取的策略为:校验所有提交文件中的 Vue 文件的最上层公共父目录,代码如下:
const getParentDir = (pathsArr = []) => {
// 获取 公用父目录
if (pathsArr.length == 1) { // 如果只有 1 层时,直接返回
return pathsArr[0];
}
const res = [];
pathsArr.reduce((pre, cur) => {
if (pre) {
const preArr = pre.split('/');
const curArr = cur.split('/');
const minLen = Math.min(preArr.length, curArr.length);
let newPath = '';
let i = 0;
while (i < minLen) { // 双指针逐步比较
if (preArr[i] !== curArr[i]) {
newPath = curArr.slice(0, i).join('/');
if (!res.includes(newPath)) {
res.push(newPath);
}
return newPath;
}
i++;
}
if (i === minLen) {
newPath = curArr.slice(0, minLen).join('/');
if (!res.includes(newPath)) {
res.push(newPath);
}
return newPath;
}
}
return cur;
});
return res[0];
};
调用 vue-type-check ,如下:
const { check } = require('vue-type-check');
const lintVue = () => {
const validVueFile = (res) => {
let result = [];
try {
result = res.split(/\n/);
const files = result.filter((p) => /.vue?$/.test(p));
const dirPaths = []; // vue-type-check 只能校验目录 不能是文件
files.forEach((file) => { // 其实这里可以省去,可以跟 getParentDir 整合到一起
const p = path.resolve(file, '../');
if (!dirPaths.includes(p)) {
dirPaths.push(p);
}
});
check({
workspace: '.',
srcDir: getParentDir(dirPaths),
excludeDir: 'node_modules',
});
} catch (e) {
console.log(e);
process.exitCode = 1;
}
};
getDiffFile(validVueFile);
};
简单优化
开发到这,其实已经基本完事了,但是可以再加点简单的优化,优化下体验,这里需要新加入一个第三方库cli-color
,用于控制控制台的输出颜色,使用也很简单,如下:
const clc = require('cli-color');
const error = clc.red.bold;
const warn = clc.red;
// 示例
console.log(error(`git diff命令出错,错误原因为: ${data}`));
笔者还想只通过这一个文件来执行 ts 类型的校验以及 vue 文件的校验,因此我们需要读取一下 cli 的参数,如下:
const { argv } = process; // process 是 node 自带的全局变量,切忌使用 let process = xxx 代码给替换了
if (argv[2] === 'tsc') {
lintTsc();
} else if (argv[2] === 'vue') {
lintVue();
}
这样我们就可以通过node lint.js tsc
或者node lint.js vue
来执行我们需要的逻辑了。
给 pre-commit 加上配置
// package.json 文件
{
"scripts": {
"lint": "eslint '**/src/**/*.{js,jsx,ts,tsx,vue}' --ignore-path .gitignore",
"lint:tsc": "node ./lint.js tsc",
"lint:vue": "node ./lint.js vue"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
}
},
"lint-staged": {
"linters": {
"**/src/**/*.{tsx,ts}": [
"npm run lint:tsc"
],
"**/src/**/*.vue": [
"npm run lint:vue"
],
"**/src/**/*.{js,jsx,tsx,ts,vue}": [
"npm run lint",
"git add"
]
},
"ignore": [
"package.json"
]
}
}
完整的 lint.js 文件
#!/usr/bin/env node
const { execSync, exec } = require('child_process');
const path = require('path');
const fs = require('fs');
const clc = require('cli-color');
const { check } = require('vue-type-check');
const error = clc.red.bold;
const warn = clc.red;
const tsconfig = require('./tsconfig.json');
const execute = (cmd, needSync = false) => {
const main = (error, stdout) => {
if (error) {
return error;
}
return stdout;
};
if (needSync) {
return execSync(cmd, main);
}
return exec(cmd, main);
};
const getDiffFile = (cb = () => {}) => {
const childProcess = execute('git diff --name-only --cached');
childProcess.stdout.on('data', cb);
childProcess.stderr.on('data', (data) => {
console.log(error(`git diff命令出错,错误原因为: ${data}`));
process.exitCode = 1;
});
};
const copyFileSync = (from, to) => {
const content = fs.readFileSync(from);
fs.writeFileSync(to, content);
};
const lintTsc = () => {
const tsc = path.resolve(__dirname, 'node_modules/.bin/tsc');
const tsCheck = () => {
const childProcess = execute(`${tsc} --noEmit`);
childProcess.stdout.on('data', (data) => {
console.log(warn(data));
});
childProcess.stderr.on('data', (data) => {
console.log(error(`tsc调用出错,错误原因为: ${data}`));
process.exitCode = 1;
});
childProcess.on('close', (code) => {
// 还原配置 & 删除冗余备份
copyFileSync('./tsconfig_cp.json', './tsconfig.json');
fs.unlinkSync('./tsconfig_cp.json');
if (code !== 0) {
process.exitCode = code;
}
});
};
const validTsFile = (res) => {
let result = [];
try {
result = res.split(/\n/);
const files = result.filter((p) => /.tsx?$/.test(p));
copyFileSync('./tsconfig.json', './tsconfig_cp.json'); // 备份配置
tsconfig.include = ['src/type/*.ts', '项目 type 文件目录']; // 去除冗余校验,只校验 type
tsconfig.files = files;
fs.writeFileSync(path.resolve(__dirname, './tsconfig.json'), JSON.stringify(tsconfig));
tsCheck();
} catch (e) {
console.log(error(e));
process.exitCode = 1;
}
};
getDiffFile(validTsFile);
};
// Vue 相关
const getParentDir = (pathsArr = []) => {
// 获取 公用父目录
if (pathsArr.length === 1) {
return pathsArr[0];
}
const res = [];
pathsArr.reduce((pre, cur) => {
if (pre) {
const preArr = pre.split('/');
const curArr = cur.split('/');
const minLen = Math.min(preArr.length, curArr.length);
let newPath = '';
let i = 0;
while (i < minLen) {
if (preArr[i] !== curArr[i]) {
newPath = curArr.slice(0, i).join('/');
if (!res.includes(newPath)) {
res.push(newPath);
}
return newPath;
}
i++;
}
if (i === minLen) {
newPath = curArr.slice(0, minLen).join('/');
if (!res.includes(newPath)) {
res.push(newPath);
}
return newPath;
}
}
return cur;
});
return res[0];
};
const lintVue = () => {
const validVueFile = (res) => {
let result = [];
try {
result = res.split(/\n/);
const files = result.filter((p) => /.vue?$/.test(p));
const dirPaths = []; // vue-type-check 只能校验目录 不能是文件
files.forEach((file) => {
const p = path.resolve(file, '../');
if (!dirPaths.includes(p)) {
dirPaths.push(p);
}
});
check({
workspace: '.',
srcDir: getParentDir(dirPaths),
excludeDir: 'node_modules',
});
} catch (e) {
console.log(error(e));
process.exitCode = 1;
}
};
getDiffFile(validVueFile);
};
const { argv } = process;
if (argv[2] === 'tsc') {
lintTsc();
} else if (argv[2] === 'vue') {
lintVue();
}
运行示例: