ESLint 之插件 & 原理

1,751 阅读8分钟

ESLint rules/extends/plugins

rules

官网所例举的核心规则前面有些有 ☑️ ,有些有 🔧,有些同时有 ☑️ 🔧,有些什么都没有。

☑️:表示 extends: 'eslint:recommended' 中包含的规则。(2023/7/2 更新:官网链接中 ✅ 且高亮的才是 recommended 中包含的规则,置灰的不是)

🔧:表示能通过 --fix 修正的规则。(2023/7/2 更新:官网链接中 🔧 且高亮的才能被 --fix 修复,置灰的不能被 --fix 修复)

"no-console": 0,
"no-debugger": 1,
"no-trailing-spaces": 2,
"quotes": [2, "single"], // 表示字符应使用单引号,如果违反规则,将报 error 级别的错误。
  • off 或 0:关闭规则
  • warn 或 1:开启规则,warn 级别的错误 (不会导致程序退出)
  • error 或 2:开启规则,error 级别的错误(当被触发的时候,程序会退出)

extends

默认所有规则都不生效,可通过 extends 指定应用哪些规则。例如 extends: 'eslint:recommended' 表示应用 eslint rules 中打勾的那些规则。

它可以配置为:

  • String:一个配置文件的路径 或 可共享配置的名称(eslint:recommended eslint:all)。
  • Array[String]:多个配置组合,后面的配置继承并覆盖前面的配置。

可共享配置(shareable configuration):它是一个配置对象的 npm 包,使用时确保已经安装到 ESLint 能够引用的目录中,在 extends 中使用时可以省略 eslint-config-。例如:你需要校验 React 风格的代码,那么需要 extends: 'eslint-config-react',也可以写成 extends: 'react'

// .eslintrc.js
module.exports = {
  extends: [  // Array[String]
    'eslint:recommended', // 可共享配置的名称
    './path-to-config', // 配置文件的路径
    'eslint-config-react', // 全称
    'react', // 缩写
  ],
  extends: 'eslint:recommended' // String
};

plugins

虽然官方提供了上百种规则可供选择,但是这还不够,因为官方的规则只能检查标准的 JavaScript 语法,如果你写的是 JSX 或者 TypeScript,ESLint 的规则就开始束手无策了。

这个时候就需要安装 ESLint 插件,来定制一些特定规则进行检查。ESLint 插件与 extends 一样有固定的命名格式,以 eslint-plugin- 开头,使用的时候也可以省略这个头。

举个例子,我们要在项目中使用 TypeScript,需要将解析器改为 @typescript-eslint/parser,同时需要安装@typescript-eslint/eslint-plugin 插件来拓展规则,添加的 plugins 中的规则默认是不开启的,我们需要在 rules 中开启要使用的规则。也就是说 plugins 是要和 rules 结合使用的。如下所示:

// npm i --save-dev @typescript-eslint/eslint-plugin    // 注册插件
{
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],   // 引入插件
  "rules": {
    "@typescript-eslint/rule-name": "error"    // 使用插件规则
    '@typescript-eslint/adjacent-overload-signatures': 'error',
    '@typescript-eslint/ban-ts-comment': 'error',
    ...
  }
}

rules 中写一大堆的配置来启用 @typescript-eslint/eslint-plugin 插件规则,十分麻烦,这时候 extends 派上了用场。

{
  extends: 'plugin:@typescript-eslint/recommended'
}

新建一个 ESLint 插件

插件目标:禁止项目中 setTimeout 的第二个参数是数字。例如:setTimeout(() => {}, 2) 是违背规则,const num = 2;setTimeout(() => {}, num) 是 ok 的。

项目初始化

  • 全局安装 eslint plugin 脚手架工具,ESLint 官方为了方便开发者开发插件,提供了使用 Yeoman 模板 (generator-eslint) 。

    npm install -g yo generator-eslint
    
  • 初始化项目目录

    mkdir eslint-plugin-irenePugin
    cd eslint-plugin-irenePugin
    yo eslint:plugin
    

    下面进入命令行交互流程,结束后会生成自定义插件的项目目录

    ? What is your name? irene
    ? What is the plugin ID? irenelint // 插件ID
    ? Type a short description of this plugin: for testing creating a eslint plugin // 插件描述
    ? Does this plugin contain custom ESLint rules? Yes // 是否包含自定义 ESLint 规则
    ? Does this plugin contain one or more processors? No // 是否包含一个或多个处理器
       create package.json
       create lib/index.js
       create README.md
    
  • 创建规则

    yo eslint:rule
    

    下面进入命令行交互流程,结束后会生成一个规则文件的模板

    ? What is your name? irene
    ? Where will this rule be published? ESLint Plugin // 规则将在哪里发布
    ❯ ESLint Core    // 官方核心规则
      ESLint Plugin  // ESLint 插件
    ? What is the rule ID? settimeout-no-number // 规则ID
    ? Type a short description of this rule: the second param of setTimeout is forbidden to use number // 规则描述
    ? Type a short example of the code that will fail: setTimeout(() => {}, 2) // 失败例子的代码
       create docs/rules/settimeout-no-number.md
       create lib/rules/settimeout-no-number.js
       create tests/lib/rules/settimeout-no-number.js
    
  • 生成的项目目录如下

    ├── README.md
    ├── docs // 使用文档
    │   └── rules // 所有规则的文档
    │       └── settimeout-no-number.md // 具体规则文档
    ├── lib // eslint 规则开发
    │   ├── index.js 引入 + 导出 rules 文件夹的规则
    │   └── rules // 此目录下可以构建多个规则
    │       └── settimeout-no-number.js // 规则细节
    ├── package.json
    └── tests // 单元测试
        └── lib
            └── rules
                └── settimeout-no-number.js // 测试该规则的文件
    
  • 安装项目依赖

    npm install
    

规则模版

打开 lib/rules/settimeout-no-number.js,可以看到通过上述命令行操作后生成的模版。

module.exports = {
  meta: {
    docs: {
      description: "the second param of setTimeout is forbidden to use number",
      category: "Fill me in",
      recommended: false
    },
    fixable: null,  // or "code" or "whitespace"
    schema: [
      // fill in your schema
    ]
  },
  create: function(context) {
    return {
      // give me methods
    };
  }
};

create 方法返回的是一个 key 为选择器,value 为回调函数(参数是 AST node)的对象,例如:{ 'CallExpression': (node) => {} },ESLint 会收集所有生效规则监听的选择器以及对应的回调函数,在遍历 AST 时,每当匹配到选择器,就会触发该选择器对应的回调。

AST:Abstract Syntax Tree

ESLint 是通过将代码解析成 AST 并遍历它实现代码校验和格式化的,具体将在下面讨论。现在我们先看下 setTimeout(() => {}, 2) 解析成的 AST 是什么样子。在线AST

编写规则

通过观察生成的 AST,过滤出我们要选中的代码,对代码的值进行判断。

// lib/rules/settimeout-no-number.js
module.exports = {
  meta: {
    docs: {
      description: "the second param of setTimeout is forbidden to use number",
      category: "Fill me in",
      recommended: false
    },
    fixable: null,  // or "code" or "whitespace"
    schema: [
      // fill in your schema
    ]
  },
  create: function(context) {
    return {
      // give me methods
      'CallExpression': (node) => {
        if (node.callee.name !== 'setTimeout') return // 不是 setTimeout 直接过滤

        const timeNode = node.arguments && node.arguments[1] // 获取第二个参数
        if (!timeNode) return

        if (timeNode.type === 'Literal'  && typeof timeNode.value === 'number') {
          context.report({
              node,
              message: 'setTimeout 第二个参数禁止是数字'
          })
        }
      }
    };
  }
};

测试用例

提供一些违背和通过规则的测试代码

// tests/lib/rules/settimeout-no-number.js
var rule = require("../../../lib/rules/settimeout-no-number"), RuleTester = require("eslint").RuleTester;

var ruleTester = new RuleTester();
ruleTester.run("settimeout-no-number", rule, {
  valid: [
    {
      code: "let num = 1000; setTimeout(() => { console.log(2) }, num)",
    },
  ],
  invalid: [
    {
      code: "setTimeout(() => {}, 2)",
      errors: [
        {
          message: "setTimeout 第二个参数禁止是数字", // 与 rule 抛出的错误保持一致
          type: "CallExpression", // rule 监听的对应钩子
        },
      ],
    },
  ],
});

自动修复

  • fixable: 'code' 开始修复功能;
  • context.report() 提供一个 fix 函数;
// lib/rules/settimeout-no-number.js
module.exports = {
  meta: {
    docs: {
      description: "the second param of setTimeout is forbidden to use number",
      category: "Fill me in",
      recommended: false
    },
    fixable: 'code',
  },
  create: function(context) {
    return {
      // give me methods
      'CallExpression': (node) => {
        if (node.callee.name !== 'setTimeout') return // 不是 setTimeout 直接过滤

        const timeNode = node.arguments && node.arguments[1] // 获取第二个参数
        if (!timeNode) return

        if (timeNode.type === 'Literal'  && typeof timeNode.value === 'number') {
          context.report({
            node,
            message: 'setTimeout 第二个参数禁止是数字',
            fix(fixer) {
              const numberValue = timeNode.vlaue;
              const statementString = `const num = ${numberValue}\n`;
              return [
                fixer.replaceTextRange(node.arguments[1].range, 'num'),
                fixer.insertTextBeforeRange(node.range, statementString)
              ]
            }
          })
        }
      }
    };
  }
};

调试

点击 debug,然后选中项目。

点击设置,会打开一个 launch.json,program 字段填上要 debug 的文件。

在 lib/rules/settimeout-no-number.js 打 debugger,点击启动程序。

发布插件

  • 登陆 npm:npm login

  • 发布 npm 包:npm publish

使用

  • 安装插件

    npm install --save-dev eslint-plugin-irenelint
    
  • 引入插件并开启规则

    • 通过 plugins

      // .eslintrc.js
      module.exports = {
        plugins: [ 'irenelint' ],
        rules: {
          'irenelint/settimeout-no-number': 'error'
        }
      }
      
    • 通过 extends

      因为 plugins 中的规则默认是不启用的,需要一条条的在 rules 中开启,当规则比较多的时候,写起来太麻烦,这时就可以使用 extends。

      首先,我们需要修改下 lib/index.js

      // lib/index.js
      var requireIndex = require("requireindex");
      
      const output = {
        rules:  requireIndex(__dirname + "/rules"), // 所有规则
        configs: {
          recommended: {
            plugins: ['irenelint'], // 引入插件
            rules: {
              'irenelint/settimeout-no-number': 'error' // 开启规则
            }
          }
        }
      }
      
      module.exports = output;
      

      然后使用 extends

      // .eslintrc.js
      module.exports = {
        extends: [ 'plugin:irenelint/recommended' ]
      }
      

测试

修复前:第一条提示就是自动修复的提示

修复后:如果配置了保存时自动修复,就会在保存的时候自动改正。

ESLint 原理

eslint/lib/linter/lint.js

假设待校验的文件内容是

console.log('irene');

依据文件内容生成如下 AST ,将每一个节点传入 nodeQueue 队列中,每个会被传入两次;在线AST

nodeQueue = [
  {
    isEntering: true,
    node: {
      type: 'Program',
      body: [Array],
      sourceType: 'module',
      range: [Array],
      loc: [Object],
      tokens: [Array],
      comments: [],
      parent: null
    }
  },
  {
    isEntering: true,
    node: {
      type: 'ExpressionStatement',
      expression: [Object],
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: true,
    node: {
      type: 'CallExpression',
      callee: [Object],
      arguments: [Array],
      optional: false,
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: true,
    node: {
      type: 'MemberExpression',
      object: [Object],
      property: [Object],
      computed: false,
      optional: false,
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: true,
    node: {
      type: 'Identifier',
      name: 'console',
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: false,
    node: {
      type: 'Identifier',
      name: 'console',
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: true,
    node: {
      type: 'Identifier',
      name: 'log',
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: false,
    node: {
      type: 'Identifier',
      name: 'log',
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: false,
    node: {
      type: 'MemberExpression',
      object: [Object],
      property: [Object],
      computed: false,
      optional: false,
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: true,
    node: {
      type: 'Literal',
      raw: "'irene'",
      value: 'irene',
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: false,
    node: {
      type: 'Literal',
      raw: "'irene'",
      value: 'irene',
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: false,
    node: {
      type: 'CallExpression',
      callee: [Object],
      arguments: [Array],
      optional: false,
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: false,
    node: {
      type: 'ExpressionStatement',
      expression: [Object],
      range: [Array],
      loc: [Object],
      parent: [Object]
    }
  },
  {
    isEntering: false,
    node: {
      type: 'Program',
      body: [Array],
      sourceType: 'module',
      range: [Array],
      loc: [Object],
      tokens: [Array],
      comments: [],
      parent: null
    }
  }
]

遍历所有整合好的规则,如果该条规则不为 0 或 'off'(即规则是开启的),获取该条规则的 rule 对象,执行 create 函数返回监听对象,它表明了这条规则监听了哪些 AST 节点,当遍历这些节点的时候就会执行对应的回调函数;

// 假设整合好的规则如下
configuredRules = {
  '@typescript-eslint/no-explicit-any': [ 0 ], // ruleId: [severity]
  '@typescript-eslint/explicit-module-boundary-types': [ 0 ],
  'prettier/prettier': [ 'error' ],
  '@typescript-eslint/no-unused-vars': [ 'warn' ],
  ...
}
ruleObj = {
  meta: 
  create: (context) => {
    return {
      'CallExpression:exit': func1,
      'Identifier': func2
    }
  }
}

遍历该条规则的监听对象,为每个 AST 节点注册监听函数

listeners: {
  'CallExpression:exit': [func1],
  Identifier: [func2]
}

遍历规则结束之后,我们得到了一个 listeners 对象,key 是 AST 节点,value 是回调函数数组;

遍历第一步获取到的 nodeQueue,触发 listeners 中对应的回调函数,比如遍历到 CallExpression 的时候,去执行 listeners.CallExpression 数组里的函数,函数会检测当前节点是否违背规则,如果违背,则报告 warn/error,存于 lintingProblems 中;

返回 lintingProblems

参考

其他