浅析eslint校验流程并实现一个插件

avatar

Eslint是团队合作coding必不可少的利器,ESLint通过各种规则对代码添加约束。通过合适的规则约束,能让我们的代码更健壮,工程更可靠,增加可读性。

当我们遇到想要统一的规范时,一般会找现有的规则,大部分情况是可以解决问题。那么如果目前没有可用的规则,如何处理?依赖人为限制?code review?

我们先简单回顾一下eslint配置,然后了解一下eslint运行、校验规则的原理,最后实现一套自己的代码规则。

eslint 配置

eslint 支持如下文件类型,并且当同一目录下存在多个配置文件时,文件优先级:

1.eslintrc.js 
2.eslintrc.cjs 
3.eslintrc.yaml 
4.eslintrc.yml
5.eslintrc.json 
6.package.json  eslintConfig 属性

配置文件,如.eslintrc.js中,常用配置如下:

{ 
    "env":{
      "browser":true,
    },
    "plugins": [ 
      "vue" // 对 eslint-plugin-vue 的缩写
    ], 
    "extends": [ 
      "eslint:recommended",
      "plugin:vue/recommended" // 对该插件下的某一类配置的引用 
    ], 
    "rules": { 
      "vue/require-v-for-key": "error" // 对具体eslint-plugin-vue插件下的某个规则的使用 
    },
    "globals":{
        "my":"readonly", // 只读
        "wx":"writable" // 可写
    },
    "parserOptions":{
          "ecmaVersion":6,
          "sourceType":"module",
          "ecmaFeatures":{
              "jsx":true
          }
      },
    "parser": "@typescript-eslint/parser",
}
  • env 当前可以使用哪个环境的全局变量,如
    • browser,浏览器环境使用,可以使用windows下的变量,比如不配置则document会报错(前提是要配置了extends:eslint:recommended
    • es6,es6环境,支持es6语法
  • plugins:配置定义在插件中的规则,plugins为由插件名称组成的列表。可以省略插件名称中的 eslint-plugin-前缀,
  • extends:使用扩展,可以继承另一个配置文件的所有特征(包括规则、插件和语言选项)并修改所有选项。可以是一个eslint配置文件的路径,可以是下载的npm包或者插件的名称,可以是eslint推荐的一些风格例如eslint:recommendedeslint:all
  • rules 配置具体的规则及规则严重性,no-console:1
    • "off" / 0 : 关闭规则
    • "warn" / 1: 打开规则作为警告
    • "error" / 2 : 打开规则作为错误
  • global 全局变量,eslint 不允许出现未定义的变量,如果运行在小程序里,存在my、wx等全局变量,直接使用会报错,此时需要向ESlint规则中添加需要辨认的变量
  • parserOptions 语言选项配置,默认支持 ECMAScript 5,
    • ecmaVersion : 指定想要使用的 ECMAScript 版本
    • sourceType: js模块语法,默认“script”在ECMAScript模块中需要设置为“module”
    • ecmaFeatures:指定需要哪些附加选项,如需要允许使用jsx,配置"jsx":true
  • parser: 指定eslint用何种方式将代码解析成AST,默认为Espree,比较常用的配置为@typescript-eslint/parser ,将TypeScript 转换为与 ESTree 格式。

💡 extendsplugin的区别—— extends= plugin+rules

如果只配pluginplugin会加载但是规则不会生效,要配合rules去配置具体的规则使用;

extends提供了一个方法可以将插件中推荐or导出的规则集合同时生效,当然也可以配置单独rule去覆盖

eslint 运行原理

ESLint 将代码解析为抽象语法树AST,通过遍历AST,在遍历到不同的节点或者合适的时机的时候,触发相应的规则函数进行校验,如未通过校验则抛出错误,如需修复则尝试进行修复。

运行原理

1、处理命令行命令&初始化实例

ESLint 首先读取命令行的配置,进行处理,包含--init则进行eslint初始化,无则进入校验逻辑,并且生成一个ESLint实例,其中又会生成一个CLIEngine实例和一个Linter实例。在ESLint实例生成后,根据参数做不同的处理,但是主要是遍历文件并进行校验。

这三个为ESLint的核心类,包含了如下功能,

2、读取eslint配置

校验文件时需要获取eslint的配置,首先按照配置文件的优先级读取配置,再递归处理 extends,最后返回自己的配置,所以最终得到的配置list中里的顺序则是:配置中的 extends > 配置。

读取流程

3、使用parser解析text获得AST

获取到配置后接着对每个文件进行校验,校验过程会循环进行校验->修复,当满足以下两者之一时则结束循环:

  • 没有更多错误需要处理
  • 满足最大校验次数

在校验中,如没有指定的parser,eslint 则默认使用 JavaScript 解析器 Espree 把JS代码解析成AST。

4、运行具体规则并实现校验

  • 获取到AST之后,ESLint会以从上至下再从下至上的顺序遍历AST,将节点加到节点队列中。
  • 再遍历所有配置的规则,将规则返回的选择器添加为监听器。此时会获取规则配置的严重性,0 | 1 | 2,如果是关闭状态就转到下一条规则。
  • 再次遍历节点队列,判断节点是否触发监听器,触发则执行回调校验。
  • 最后返回校验的结果,一个问题列表,包括错误的列、错误提示信息等。

5、修复

如果需要修复,则遍历需要修复的问题,并且调用规则的修复方法

6、过滤并输出结果

根据代码里的 eslint 注释配置来过滤问题列表并输出

选择器

选择器 (Identifier)是可以用来匹配抽象语法树(AST)中节点的字符串,类似css的选择语法。

选择器并不局限于对单一节点类型的匹配。例如,选择器 VariableDeclarator > Identifier ,将匹配所有以 VariableDeclarator 为直接父节点的 Identifier 节点。

支持的选择器有:

  • AST节点类型
  • 通配符:*(匹配所有节点)
  • 第一个或最后一个子项::first-child 或 :last-child
  • 第 n 个子项(不支持 ax+b)::nth-child(2)
  • 最后 n 个子项(不支持 ax+b)::nth-last-child(1)
  • AST 节点类::statement、:expression、:declaration、:function 或 :pattern
  • 后裔:FunctionExpression ReturnStatement
  • 选择其子项:UnaryExpression > Literal
  • 随同兄弟:VariableDeclaration ~ VariableDeclaration
  • 相邻兄弟:ArrayExpression > Literal + SpreadElement
  • ...更多可参见:官网

例:

  // 选中所有 有块的 IfStatement 节点。
  "IfStatement > BlockStatement": function(blockStatementNode) {
    // 
  },

  // 选中所有 有 3个 以上参数的函数声明。
  "FunctionDeclaration[params.length>3]": function(functionDeclarationNode) {
    // 
  }

实现一个eslint插件

根据上述运行原理,我们了解规则主要是在第4步运行具体规则并实现校验中生效,下面实现一个eslint插件,以如下规则为例:

禁止项目中使用console.time()

1、脚手架安装

ESLint官方为了方便开发者开发插件,提供了一个脚手架工具,Yeoman模板(generator-eslint),用于生成包含指定框架结构的工程化目录结构。以下指令进行安装,

npm install -g yo generator-eslint

2、命令行初始化ESLint插件

生成ESLint插件的项目模板

yo eslint:plugin

创建时的交互如下,processors是用于处理js以外的语言时添加,

 $ yo eslint:plugin
? What is your name? yn
? What is the plugin ID? eslint-demo
? Type a short description of this plugin: demo
? Does this plugin contain custom ESLint rules? Yes
? Does this plugin contain one or more processors? No
   create package.json
   create eslint.config.mjs
   create lib/index.js
   create README.md

初始化生成文件及结构:

3、创建规则文件

生成ESLint插件具体规则的文件

yo eslint:rule

交互

$ yo eslint:rule  
? What is your name? yn
? Where will this rule be published? ESLint Plugin
? What is the rule ID? yn-demo-rule // 规则ID
? Type a short description of this rule: settimeout第二个参数禁止为数字
? Type a short example of the code that will fail: empty
   create docs/rules/yn-demo-rule.md
   create lib/rules/yn-demo-rule.js
   create tests/lib/rules/yn-demo-rule.js

文件及结构:

可以看到刚刚空的文件夹下多了以rule ID 命名的文件。lib/rules/*.js为具体的规则,tests/lib/rules/*.js为对应的测试文件。

4、编写具体规则

进入到刚刚生成的文件 lib/rules/no-console-time.js

/**
 * @fileoverview 禁止console.time()
 * @author yn
 */
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: null, // `problem`, `suggestion`, or `layout`
    docs: {
      description: "禁止console.time()",
      recommended: false,
      url: null, // URL to the documentation page for this rule
    },
    fixable: 'code', // Or `code` or `whitespace`
    schema: [], // Add a schema if the rule has options
    messages: {
      avoidMethod:"console method '{{name}}' is forbidden."
    }, // Add messageId and message
  },

  create(context) {
    return {
      // visitor functions for different types of nodes
    };
  },
};

上述初始化为编写规则的模板,一个规则对应一个可导出的node模块,其主要由 metacreate 两部分组成,其中,

  • meta 代表了这条规则的元数据,如其类别,文档,可接收的参数的schema等等,
    • type表示规则的类型,是 "problem"、"suggestion" 或 "layout" 其中之一,
    • docs核心规则必须存在此字段,而自定义规则则可自行选择。
      • description:在规则页面中提供规则的简短描述。
      • recommended:表示在配置文件 中是否使用 "extends": "eslint:recommended" 属性启用该规则。
      • url:指定可以访问完整文档的链接(使代码编辑器能够在突出显示的规则违反上提供一个有用的链接)
    • fixable "code" 或 "whitespace",在需要修复时是强制指定的。如果命令行存在 --fix 选项,fixable却没有指定则会抛错。
    • schema:用于验证规则的配置选项
    • messages用于指定在report返回错误时,代替打印信息
meta:{
  messages: {
      avoidMethod: "console method '{{name}}' is forbidden.",
  },
}
create(context){
  return {
      // ...
      context.report({
         context.report({
             /// ...
             node,
             messageId: 'avoidMethod',
             data: {
                name: 'time',
             },
           
             fix(fixer){
               //...
               return //
             }
        });
      })
  }
}

详细的描述可见官方文档《Custom Rules》。

  • create表达这条 rule 具体会怎么分析代码。Create 返回的是一个对象,其中最常见的键的名字可以是上面我们提到的AST选择器,在该选择器中,我们可以获取对应选中的内容,随后我们可以针对选中的内容作一定的判断,看是否满足我们的规则,如果不满足,可用 context.report() 抛出问题,ESLint 会利用我们的配置对抛出的内容做不同的展示。
    • create方法的入参,这个context对象包含了我们这条自定义规则的上下文相关的信息,如下,
      • id:规则id
      • fileName:与源关联的文件路径
      • parserOptions:此配置运行的解析器,为.eslintrc.jsparserOptions 配置
      • options:通过这个可以拿到规则传进来的参数
      • getScope(): 返回当前遍历节点的作用域
      • sourceCode: 返回SourceCode对象,就是源码对象

sourceCode是获取有关正在检查的源代码的主要对象,通过 sourceCode.ast 可以获得当前校验代码的ast

    • report(): 当校验不通过的时候,通过这个方法输出错误信息
      • message:问题提示信息,等效于meta 中的 messageId
      • node:与问题哟管的AST节点
      • fix(fixer):修复函数,在指定需要fix时执行,同时需要指定meta.fixabletruefixer 为传入的对象,存在一些方法,如在给定的节点(范围)或标记后/前插入文本、删除/移除置顶的节点等。
      • data:提供给message中的占位符

根据上述的eslint运行原理,我们知道是需要通过选择AST的节点并且进行校验的,于是我们观察一下 console.time()方法转化成的AST:

通过 astexplorer.net 链接可以查看代码被解析成AST的样子

利用其中以下内容判断代码是否含有 console.time

于是编写 create 规则如下:

create(context) {
  return {
    'CallExpression MemberExpress':(node)=>{
      if(node.property.name==='time'&&node.object.name==='console'){
        // 不满足规则
        context.report({
          node,
          messageId:'avoidMethod',
          data:{
            name:'time'
          }
        })
      }
    }
  };
},

5、输出规则

lib/index 下输出规则,如

// import all rules in lib/rules
module.exports = {
  // 元数据
  meta:{
    name:pkg.name,
    version:pkg.version
  },
  // 规则
  rules:{
    'no-console-time':require('./rules/no-console-time')
  },
  processor:{
  },
  // 插件中的配置
  configs:{
    myConfig:{
      plugins:['eslint-yn-demo'],
      rules:{
        'eslint-yn-demo/no-console-time':2,
        semi:'error',
      },
    }
},
}

其中,

  • meta元数据,为了更容易进行调试和有效的缓存插件,官方建议配置meta对象,meta一般和package中保持一致
  • rules 对象必须被输出,包含一个规则ID到规则实现的映射,规则命名不需要遵循惯例。使用这个配置时,按照 插件名/规则Id 引用,如 {rules:eslint-yn-demo/no-console-time}
  • processor:用于处理JS以外的文件,对应第3步在verify校验时,如果需要处理js以外的文件会执行
  • config为插件中的配置 ,可以集合多条规则,其中plugins 为插件名称,rules则是配置的规则如果要使用 myConfig下的规则,则在 eslint 配置中配置如下,
// .exlintrc.js
{
  extends:[
      "plugin:eslint-yn-demo/myConfig"
  ],
}

6、测试规则

tests/lib/rules/no-console-time.js下编写单元测试,

'use strict';

// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------

let rule = require('../../../lib/rules/no-console-time');

let RuleTester = require('eslint').RuleTester;

// ------------------------------------------------------------------------------
// Tests
// ------------------------------------------------------------------------------

let ruleTester = new RuleTester();

ruleTester.run('no-console-time', rule, {
    // 合法示例
    valid: [ 
        '_.time({a:1});',
        "_.time('abc');",
        "_.time(['a', 'b', 'c']);",
        "lodash.time('abc');",
        'lodash.time({a:1});',
        'abc.time',
        "lodash.time(['a', 'b', 'c']);",
    ],
    // 不合法示例
    invalid: [ 
        {
            code: 'console.time()',
            errors: [{ messageId: 'avoidMethod',}],
        },
        {
            code: "console.time.call({}, 'hello')",
            errors: [
                {
                    messageId: 'avoidMethod',
                },
            ],
        },
        {
            code: "console.time.apply({}, ['hello'])",
            errors: [
                {
                    messageId: 'avoidMethod',
                },
            ],
        },
        {
            code: 'console.time.call(new Int32Array([1, 2, 3, 4, 5]));',
            errors: 1,
        },
    ],
});

运行 npm run test,输出如下,

至此,一个简单的eslint插件就开发完成了,接下来是使用的部分

7、安装到项目

在安装前还会有一个发布的操作,本次demo就不发布了,本地link测试看看,初始化一个简单的项目,引入eslint并新增配置.eslintrc.js配置,

module.exports = {
    plugins:[
        'eslint-yn-demo'
    ],
    rules:{
        "eslint-yn-demo/no-console-time":'error'
    }
}

新增一个测试文件和代码console.time(),运行 npm run lint

至此,一个简单的插件就开发和测试完成了

总结

本次通过实现和运用一个简单的eslint插件,回顾了eslint的配置、梳理了一下eslint运行流程。

参考:ESLint自定义规则:zh-hans.eslint.org/docs/latest…