[半成品] Vue2 +TS项目Pre-Commit时 Lint TS & Vue Template 错误

1,657 阅读4分钟

背景

最近项目遇到一个问题是:部门用的是 Vue2+TS 项目,Type类型自动同步于接口文档,那么容易出现部署到测试环境失败的问题。原因为后端改动接口 + 自身编码时忽略,导致 TS 的类型变更没及时修正。因此同事好奇为啥已有的 pre-lint 没有在 commit 阶段把这些问题给拦截下来,大大滴影响了测试速率。

注意: 本文为本成品,只提供思路,需要读者根据自有项目进行修改,暂未做到通用化

2021.03.25 更新

  1. 单独 Lint TS的脚本无需开发(vue-type-check 底层调用的 Typescript Language Server API),使用 onlyTypeScript 命令即可(命名容易误导)
  2. 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 文件中的代码逻辑错误,此处我们借用的是第三方库跟他有比较大的关系。目前笔者调研到的两个能用的第三方库如下:

工具名优势不足介绍
VTI1 Vetur 官方维护,质量可以保证。
2 可以校验 vue 文件中的 ts 类型推断
1 暂时无更新维护消息
2 无法校验 vue文件中的 script 片段中内容
3 无法指定校验文件/目录,即无法做增量校验
vuejs.github.io/vetur/guide…
vue-type-check1 官网 Demo 可以校验 vue 文件中的 script 片段中内容
2 可以指定校验目录(不能指定文件)
1 个人维护项目,且最新维护时间为 3 个月前
2 亲测也有问题,无法校验到 vue 文件中的 script 片段中内容
github.com/Yuyz0112/vu…

此处笔者选用的是 vue-type-check 插件,原因为两个:

  1. 支持增量校验
  2. 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();
}

运行示例: