eslint 插件 rule 开发

133 阅读8分钟

eslint 作为前端工程代码规范的一个校验库,在前端项目被广泛使用,应该是目前最流行,当然长话短说,这次分享主要讲一下eslint 插件的原理,这部分涉及ast的一些知识。

AST

eslint是一个代码检查工具,他需要将代码编译一种能够被理解的格式,通过配置文件和输入的规则来校验这个格式是否正确,这个格式是AST。

AST : (Abstract Syntax Tree)抽象语法树,是前端很多工具用的一种描述代码的格式,通过分支和节点组成。AST常用于代码转换、压缩等步骤。

为了方便理解,先贴一个转换的网站(astexplorer.net/),这里可以看到代码具体转换后的格式

主要功能有:

  • 代码转换
  • 代码压缩
  • 代码静态分析、检查
  • 生产代码

基于ast的工具 :

  • babel 代码编译、转换,比如es5 → es6,或者用babel插件去做国际化或埋点,babel插件和eslint插件很像
  • eslint、preitter 等代码格式校验检查工具
  • sourcemap : 调试的代码定位、错误代码定位到源码,大致的结构长这样,最主要就是mappings,这个也是通过代码的行列数计算的
{   
    version : 3,    
    file: "out.js",    
    sourceRoot : "",    
    sources: ["foo.js", "bar.js"],    
    names: ["src", "maps", "are", "fun"],    
    mappings: "AAgBC,SAAQ,CAAEA" 
}
  • webpack 打包过程 (比如 three shaking ),将一些没有引用、没有被调用的无关代码从你的构建产物中去除掉
// a.ts
export const a = 100;
export function add(num1: number, num2: number): number {   
    return num1 + num2;
}

// b.ts
import { add } from './a.ts';
console.log(add(100, 200));
  • UglifyJs 代码压缩

词法分析


这是编译的第一个阶段(扫描),词法分析是编译器通过读取输入的字符串,识别字符串代码的含义,生产一系列标记,这种标记被叫做tokens。比如下面的a变量和add函数,这些token一般包含运算符、关键字、常量等

const a: string = 'hello world';   
const b = 10 * (5 + 5);   
function add(a: number, b :number):number {     
    return a + b; 
}  

class Person { 	
    eat() {
    }      	
    
    sleep() {
    } 
 }  
 
 class Student extends Person {
     doHomeWork() {
     } 
 }

关于ast token 的具体可以在这里查:github.com/babel/babel…

不同的编译器编译后的ast都不太一样,摘选第一行代码分析一下,编译器通过识别关键字(const)、标识符(a: string),数值(“hello world”),最后也识别出是一段声明语句。

image.png 这里的Identifer、StringLiteral就是token,用这些节点串起来来描述代码构成。

const a: string = 'hello world';  

// Tokens 
[     
    {         
        "type": "Keyword",         
        "value": "var"     
    },     
    {         
        "type": "Identifier",
        "value": "a"
    },     
    {         
        "type": "Punctuator",         
        "value": "="     
    },     
    {       
        "type": "StringLiteral",
        "value": "'hello world'"
    } 
]

语法分析

在词法分析分词结束,需要通过文法规则(rules of grammer)将这些token串起来,生产语法树,整个过程是个递归的过程。

image.png

这里是只粗略带一下编译器的工作过程,js编译器过程很复杂,常用的js编译器:

  • ESprima
  • Acorn
  • @babel/parser
  • .......
// const a: string = 'hello world';

{   
    "type": "Program",   
    "body": [     
        {   
        "type": "VariableDeclaration", 
        "declarations": [         
            {           
                "type": "VariableDeclarator",  
                "id": {             
                    "type": "Identifier",
                    "name": "a",     
                    "typeAnnotation": {  
                        "type": "TSTypeAnnotation", 
                        "typeAnnotation": { 
                            "type": "TSStringKeyword", 
                        }
                     }
                 },          
                 "init": {
                     "type": "Literal",
                     "value": "hello world",
                 },
            }       
         ],       
         "kind": "const"     
     }]
}

代码生成阶段


在AST转换后,接下来是代码的生成阶段,这个过程是generate阶段,generate阶段是将AST打印成字符串,从一个AST的根节点进行递归打印,针对不同的AST节点做处理。

比如像if语句的IfStatement会先打印一个”if”,然后打印空格,再接着打印”(”,然后打印node的节点信息,遇到语句块的时候会判断是否需要打印”{”,因为if的括号在语法上是可以省略。

image.png

来看另一个针对Jsx的,这里两个函数是打印jsx element的,比如像OpeningElement,会打印”<”,然后打印jsx的名字、属性等,最后判断是否的自我闭合的element。

image.png 在@babel/generator会定义一系列的打印方法,可以在这里查阅

github.com/babel/babel…

使用

eslint常用的配置可以有如下,配置的权重也是有顺序的,但常用的也就第一个

  • .eslintrc.js
  • .eslintrc.cjs
  • .eslintrc.json
  • .......

eslint其实也算做跟babel有一些类似的功能,eslint在检查你的代码的时候,先得识别出你这部分的语法。

比如你用es6的语法,如果你配置的不对,可能在某些情况下eslint无法识别出来,这里面就涉及到代码的转换过程。

{ 	
   languageOptions: {
       ecmaVersion: 5, // lint 检查 ECMAScript 5
       sourceType: "module" // 文件导入导出格式
   } 
}

一些环境的配置可以参考:eslint.org/docs/latest…

配置文件

eslint在执行的过程中,会在文件目录逐级往上去检索你的配置文件,或者你通过设置root: true 的情况,在monorepo的结构中你也可以只定义一个lint配置,子项目读取这个配置来约束daim。

配置项也挺多,具体了解可以到这里看,下面主要说一下比较重要的配置:

  • parseOptions 

    (ESLint 允许指定支持的 JavaScript 语言选项或者是其他的选项,比如jsx, 这个配置在rule也有)

    • ecmaVersion: 指定 ECMAScript 版本,具体要看支持情况

    • sourceType:设置模块

    • ecmaFeatures

      • jsx: 支持jsx,
      • globalReturn: 允许全局return
  • extends: 引入插件包,比如[[@qunhe/arch-eslint-plugin]

  • rule: 规则,包括官方的和自己定义的

  • parser: 具体的解析器

// .eslintrc.js 
module.exports = {
    "parserOptions": {
        "ecmaVersion": "latest", 
        "sourceType": "module",
        "ecmaFeatures": { 
            "jsx": true
        } 	
     }, 	
     "extends": [ 		
         "@xxx/eslint-plugin-custom"
     ], 	 	
     "rule": { 		
         "custom/no-return-type": "error"
     } 
}

``

运行原理

  • 运行eslint 脚本
  • 执行eslint.js 文件
  • 获取执行的参数,比如校验的文件类型,或者禁用disable(--no-inline-config)
  • 新建eslint类实例,linter 类实例
  • fs.readFile 读取对应文件,读取配置文件,转换代码
  • 读取plugin的所有rule,然后test file by rule,获取最终的执行结果,收起起来缓存
  • 读取是否有fix配置,有的话就根据fix来改正代码

image.png

具体原理可以通过全局npm包看源码,这里不详细深入

规则(rule)开发

本文主要是介绍rule规则的开发,eslint的对rule的定义是返回一个对象,对象有meta属性和create函数

// 实际上日常对于插件的开发只需要搭建这么个框架

module.exports = {
    meta: {
    }, 	 	
    create(context) {
    } 
}

meta

meta就是对这个插件的一些描述,meta的内容包括但不限于

  • type: 表示是建议性还是问题性的规则,类比warn和 error

  • docs:描述自定义规则的一些工具属性

    • description:规则描述
    • recommended:对于核心规则,指定是否有@eslint/js 的 recommend配置调用
    • url: 对于规则描述的url,一般这个可以省略
  • fixable:eslint规则有fix功能, 在终端调用eslint —fix可以选择修复问题

  • schema:可以定义一些运行时的属性,在create的context.options访问

create


create函数接受一个context参与,context包含一些rule的能力,比如上报、获取运行参数等等,这个create函数是在eslint访问AST的过程中反复调用的,比如下面这个三个函数,分别表示eslint编译器走到对应ast的节点做的操作

create(context) {
    FunctionDeclaration() {} // 函数声明
    ImportDeclaration() {} // 导入语句
    VariableDeclaration() {} // 变量
}

 一些context的主要用途

  • id: 规则id

  • fileName:目前运行的文件名

  • cwd: 当前工作的文件路径,跟node那个差不多

  • sourceCode:一个SourceCode对象

  • languageOptions:一些代码语言版本的配置

    • sourceType:文件模式,script、module、commonjs
    • parser:对应的解析器(比如@typescript-eslint/parser、@babel/eslint-parser)
    • parserOptions:描述eslint解析代码的方式,比如严格模式、支持jsx等
    • ecmaVersion: 用于解析当前文件的 ECMA 版本
  • report(options):发布警告或错误的的方法,一般是命中校验的规则后调用

    options:

    • message:问题的解释
    • messageId; 消息Id,对于规则可以定义在特殊的Id,在全局实用
    • loc:包含两个属性start和end,表示代码的行列数,这个还是比较常见,平时eslint报错了能看到,包括sourceMap也应用这个(loc → start → { line: number; column: number;})
    • node:对应遍历的AST节点
    • fix(fixer):fix函数可以修复错误

现在来实践一下,比如说我现在想写一个没console.log函数调用的插件,填补meta部分

meta: { 		
    name: 'no-console-log', 		
    meta: { 			
        type: 'problem', 			
        docs: { 				
            description: 'could not use console.log function in code', 
            recommended: 'error'
        },
        fixable: true
    }
} 

然后根据ast工具,选取parser,拿到对应的结构,如下可以看到解析出了MemberExpression

image.png 接下来补充create部分,这里描述的是获取到console.log调用的这行代码,然后提供fix函数,可以执行fixer,将这行调用替换为空,移除掉

create(context) {
    return {
         MemberExpression(node) { 
            if (node.object && node.property) {
                const { object, property, loc } = node; 
                if (object.name === 'console' && property.name === 'log') { 
                    context.report({
                        message: 'could not use console.log',
                        loc,
                        node,
                        fixable: (fixer) => { 
                            return fixer.replaceText(node, '');
                        }
                     })
                }
            }
        }
    }
}
 // no-console-log.ts
 {
    name: "no-console-log",
    meta: {
        type: "problem",
        docs: {
            description: "Disallow console.log",
            recommended: "error"
        },
        schema: [],
        messages: {
            'noConsoleLog': "console.log is not allowed"
        }
    },
    defaultOptions: [],

    create(context) {
        return {
            MemberExpression(node: TSESTree.MemberExpression) {
                const p = node.property as any;
                const obj = node.object as any;
                if (p.name === 'log' && obj.name === 'console') {
                    context.report({
                        node,
                        loc: node.loc,
                        messageId: 'noConsoleLog',
                    })
                }
            },
        }
    }
}

到这里整体就写完啦,这个比较简单,可以去eslint的官方packages看他们实现的rule,还是挺复杂的,另外在判断节点的时候可以选取一些npm封装库,会更规范。

关于自定义规则的一些内容可以在这里

挑了个官方的rule规则:

github.com/typescript-…