编写一个自定义 TypeScript ESLint plugin

1,502 阅读3分钟

在日常的项目中,我们经常遇到需要计算的场景。但是JavaScript计算有很大的精度问题,而且在编码代码的时候会经常忽略精度,从而增加QA同学和我们自己的工作量。

image.png

为了解决精度问题,社区也为涌现很多优秀的库,在这里推荐一个小而美的库bigjs;我们不能被动的等到出了bug才来解决问题,为了准时下班,在日重的编码中,就需要考虑到精度的问题; 这时候就可以结合团队的一些规范来自定义一个eslint plugin了。

ESLint 是如何工作的?AST 的魔力

在我们开始深入创建 ESLint 规则之前,我们需要了解什么是 AST 以及为什么它们对开发人员如此有用。

AST或抽象语法树将代码表示为计算机可以读取和操作的树。

我们用高级的、人类可以理解的语言为计算机编写代码,例如 C、Java、JavaScript、Elixir、Python、Rust……但计算机不是人类:换句话说,它无法知道我们的意思写。我们需要一种方法让计算机从句法的角度解析您的代码,以理解这const是一个变量声明,{}有时标志着对象表达式的开始,有时标志着函数的开始......等等。这是通过 AST 完成的,这是一个必要的步骤.

image.png

在 ESLint 中,默认使用 esprima 来解析我们书写的 Javascript 语句,让其生成抽象语法树,然后去 拦截 检测是否符合我们规定的书写方式,最后让其展示报错、警告或正常通过。但目前我们都使用了typescript,所有需要将ts 代码转换为ast语法书,这时候就需要另外一个解析器 @typescript-eslint/parser

来看个简单的例子

image.png 可以看到和js代码解析出来的语法树相比,ts代码的抽象语法树带上类型信息。

创建plugin

eslint文档可以一个plugin主要是有rules 和 我们平常在eslintrc.js 定义的一些配置相关

module.exports = {
    rules: {
        "dollar-sign": {
            create: function (context) {
                // rule implementation ...
            }
        }
    },
    config: {
    
    }
};

@typescript-eslint/eslint-plugin index.ts

import rules from './rules';
import all from './configs/all';
import base from './configs/base';
import recommended from './configs/recommended';
import recommendedRequiringTypeChecking from './configs/recommended-requiring-type-checking';
import eslintRecommended from './configs/eslint-recommended';

export = {
  rules,
  configs: {
    all,
    base,
    recommended,
    'eslint-recommended': eslintRecommended,
    'recommended-requiring-type-checking': recommendedRequiringTypeChecking,
  },
};

了解了插件的基本结构,我们就可以初始化项目了。

创建测试项目

为了调试和验证插件的功能,我们运行

npm i typescript -D
npx tsc --init`

简单初始化一个typecript项目

{
 "compilerOptions": {
   "target": "ES6",
   "module": "CommonJS",
   "skipLibCheck": true,
   "moduleResolution": "node",
   "experimentalDecorators": true,
   "esModuleInterop": true,
   "sourceMap": false,
   "baseUrl": ".",
   "checkJs": false,
   "lib": ["esnext", "DOM"],
   "paths": {
     // path alias
     "@/*": [
       "src/*"
     ]
   },
 },
 "include": [
   "src/**/*.ts",
   "eslint-rules/index.ts",
   "eslint-rules/no-raw-float-calculation.ts"
 ],
 "exclude": [
   "node_modules",
 ]
}

在新建一个eslint-rules目录存放我们需要自定义的plugin,安装依赖

// eslint 相关
npm i eslint @typescript-eslint/eslint-plugin @typescript-eslint/experimental-utils @typescript-eslint/parser -D
// node 类型提示
npm i @types/node -D
// jest 相关
npm i @types/jest ts-jest jest -D

根目录初始化 eslintrc.js

image.png 按照提示一步步来即可,注意需要选择在项目中使用typescript

module.exports = {
    "env": {
        "node": true,
        "es2021": true,
    },
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/recommended",
    ],
    
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
        "ecmaVersion": "latest",
        "sourceType": "module",
        project: ['./tsconfig.json'],
    },
    "plugins": [
        "@typescript-eslint"
    ]
}

所有的步骤完后,大概的目录结构如下:

image.png

编写rule

对于如何编写typescript eslint rule, 我们可以参考官方文档的custom rule章节,我们按照文档上的模板完成自定义插件的开发。

import { ESLintUtils, TSESTree } from '@typescript-eslint/experimental-utils';

type MessageIds = 'noRawNumberCalculation';
type Options = [];

// https://typescript-eslint.io/docs/development/custom-rules
const createRule = ESLintUtils.RuleCreator(
  // rule 的说明文档链接
  name => `https://example.com/rule/${name}`,
);

/**
 * Options: rule 支持的参数
 * MessageIds: 上传错误提示的id
 */
export default createRule<Options, MessageIds>({
  name: 'no-raw-number-calculation',
  meta: {
    type: 'problem',
    docs: {
      description:
        '避免原生js number类型的四则运算,可以使用bigJs',
      recommended: 'error',
      requiresTypeChecking: true,
    },
    messages: {
      noRawNumberCalculation:
        '避免原生js number类型的四则运算,可以使用bigJs',
    },
    schema: null // rule参数说明;这里做个简单的demo,不支持参数
  },
  defaultOptions: [],
  create(context) {
    const parserServices = ESLintUtils.getParserServices(context);
    const checker = parserServices.program.getTypeChecker();

    const getNodeType = (node: TSESTree.Expression | TSESTree.PrivateIdentifier) => {
      // eslint ast 节点 和 TypeScript ts.Node 等效项的映射
      const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
      // 拿到typescript 源代码的类型信息
      // const a: number = 1 拿到a的类型信息
      return checker.getTypeAtLocation(tsNode);
    }

    const checkBinaryExpression = (node: TSESTree.BinaryExpression) => {
      const leftNodeType = getNodeType(node.left);
      const rightNodetType = getNodeType(node.right);
      // 操作符两边都是number类型
      if (leftNodeType.isNumberLiteral() && rightNodetType.isNumberLiteral()) {
        context.report({
          node,
          messageId: 'noRawNumberCalculation',
        });
      }
    }

    return {
      // +-*/ 四则运算
      "BinaryExpression[operator='+']": checkBinaryExpression,
      "BinaryExpression[operator='-']": checkBinaryExpression, 
      "BinaryExpression[operator='*']": checkBinaryExpression,
      "BinaryExpression[operator='/']": checkBinaryExpression
    }
  }
});

到此,我们完成了一个简单的rule编写,它能检测到代码中number的四则运算;当然这只是一个demo,逻辑也很简单,还有很多运算符没考虑,如:+= , ++等等。 另外,只是单纯的判断为number类型有点简单粗暴,比如如果是整型的话,其实是可以直接进行原生的四则运算;一般是涉及到浮点类型的计算,需要考虑精度运算。 但是 typscript只提供了number类型,我们可以自定义integerfloat类型。

declare type integer = number & { readonly __integer__?: unique symbol };
declare type float = number & { readonly __float__?: unique symbol };
declare type int = integer;

在真实项目,可以结合团队的脚手架工具 将上面类型加到项目的模板声明文件中。这些暂且不说,回到demo中,我们需要新建一个 index.ts导出编写的rule,

import noRawNumberCalculation from './no-raw-number-calculation';

export = {
  rules: {
    'no-raw-number-calculation': noRawNumberCalculation
  }
}

我们已经编写了一个简单rule,那怎么使用和调试呢?首先回到eslint-rules文件目录:  执行 npm init -y

{
  "name": "eslint-plugin-demo", // eslint-plugin- 开头
  "version": "1.0.0",
  "description": "",
  "main": "index.js", // index.ts 编译为 index.js
  "directories": {
    "test": "tests"
  },
  "scripts": {
    "test": "echo 'test'"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

测试使用rule

回到根目录后,执行 npm run build:rule,然后在package.json中添加 eslint-plugin-demo本地依赖,再执行 npm install

{
  "name": "custom_rule",
  "version": "1.0.0",
  "description": "a custom ts lint rule demo",
  "scripts": {
    "test": "jest", // 单测入口
    "build:rule": "tsc", // 编译eslint-rule
    "lint": "eslint ./src" // lint code
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/jest": "^27.4.1",
    "@types/node": "^17.0.21",
    "@typescript-eslint/eslint-plugin": "^5.15.0",
    "@typescript-eslint/experimental-utils": "^5.15.0",
    "@typescript-eslint/parser": "^5.15.0",
    "eslint": "^8.11.0",
    "eslint-plugin-demo": "file:./eslint-rules", // 本地依赖
    "jest": "^27.5.1",
    "ts-jest": "^27.1.3",
    "typescript": "^4.6.2"
  }
}

eslintrc.js 添加eslint-plugin-demo

"plugins": [
   // ...
   "demo"
 ],
 "rules": {
    'demo/no-number-float-calculation': 'error',
 }

现在我们可以看到编写rule的效果了:

image.png 再执行npm run lint image.png

可以看到插件已经正常运行了。 哈哈,大功告成了!

测试用例

 在eslint-rules文件下增加__test__文件夹:

image.png

为什么要建一个file.ts 文件呢? 官方描述:它是用于正常 TS 测试的空白测试文件。(其实也没很get到这个点,但是必须得加上不然测试用例无法正常跑起来)

image.png

测试用例的写法也跟普通eslint rule一样,考虑通过和不通过的场景就好。

import { ESLintUtils } from '@typescript-eslint/utils';
import rule from '../no-raw-number-calculation';

const ruleTester = new ESLintUtils.RuleTester({
  parser: '@typescript-eslint/parser',
  parserOptions: {
    tsconfigRootDir: __dirname,
    project: './tsconfig.json',
  },
});
ruleTester.run('my-typed-rule', rule, {
  valid: [
    "const a = 1; const b = '2'; console.log(a + b);"
  ],
  invalid: [
    {
      code: "const a = 2; const b = 4; console.log(a + b)",
      errors: [{ messageId: 'noRawNumberCalculation' }]
    }
  ],
});

单测的tsconfig.json

{
  "extends": "../../tsconfig.json", // 继承根目录下ts的配置
  "include": [
    "file.ts"
  ]
}

最后我们看看jest.config.js 的配置,主要是要支持解析ts文件。

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  testTimeout: 60000,
  testRegex: '/__tests__/.*.(jsx?|tsx?)$',
  collectCoverage: false,
  watchPathIgnorePatterns: ['/node_modules/', '/dist/', '/.git/'],
  moduleFileExtensions: ['ts', 'js', 'json', 'node'],
  testPathIgnorePatterns: ['/node_modules/'],
};

最后在根目录下,运行npm test

image.png 测试用例也通过啦!到这里,我们编写一个自定义plugin的开发流程就基本走完了,后续就是发布npm,这些就不演示了,毕竟这只是一个学习demo。

感谢大家能坚持看完哈!