编写ESLint插件-浅谈AST

818 阅读9分钟

AST 全称 Abstract Syntax Tree ,翻译为抽象语法树,是前端工程化绕不开的一个名词,目前前端常用的一些插件或者工具,比如说javascript 转译、代码压缩、css 预处理器、eslintpretiier 等功能的实现,都是建立在 AST 的基础之上,webpackeslint 等很多工具库的核心都是通过抽象语法书这个概念来实现对代码的检查、分析等操作。本文将会介绍 AST 的概念、原理以及用途,并通过开发一个 ESLint 插件来加深对 AST 概念的理解。

基本概念

抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

我们先看一下如下代码

import React from 'react';

再看一下抽象语法树转换后的代码

{
  "type": "Program",
  "start": 0,
  "end": 207,
  "body": [
    {
      "type": "ImportDeclaration",
      "start": 179,
      "end": 205,
      "specifiers": [
        {
          "type": "ImportDefaultSpecifier",
          "start": 186,
          "end": 191,
          "local": {
            "type": "Identifier",
            "start": 186,
            "end": 191,
            "name": "React"
          }
        }
      ],
      "source": {
        "type": "Literal",
        "start": 197,
        "end": 204,
        "value": "react",
        "raw": "'react'"
      }
    }
  ],
  "sourceType": "module"
}

AST 的本质就是一个树形结构的对象,AST 是对 JS 源码的抽象,字面量、标识符、表达式、语句、模块语法、class 语法 都有各自的抽象语法树节点(Node)。一个 AST 可以由单一的节点或是成百上千个节点构成,它们组合在一起可以描述用于静态分析的程序语法。

上例中,在最外层的 typestartendbodysourceType 中,我们主要看中间 body 部分,这里的 body 的第一个对象内容就是对应import语句,这是一个 typeImportDeclaration 类型的对象,每个对象都有 typestartend、这几个字段。

  • 其中 type 表达当前块的类型,比如ImportDefaultSpecifier 表示声明语句,FunctionDeclaration 表示函数定义,Identifier 表示标识符、BlockStatement 表示块语句、ReturnStatement 表示返回语句等。
  • start 表示该块开始的位置,end 表示该块结束的位置。
  • specifiers 是一个数组,描述的导入的具体的成员变量,source 字段标识引入的库信息,其中 value 值为引入的变量名

使用 astexplorer 可以在线将任意对象,表达式转换为 AST 语法树。

AST的用途

  • 常用各类转义、编译的插件中。比如最典型的 ES6 转换为 ES5 工具 、JSX 语法转换为 JavaScript 语法,即 babel 模块。
  • 代码语法的检查,比如代码规范工具 ESLint 模块。
  • 各类 JS/CSS/HTML 压缩工具。
  • 语法检查、代码风格检查、格式化代码、语法高亮、错误提示、自动补全等

比如说,有个函数 function a() {} 我想把它变成 function b() {},或者在 webpack 中代码编译完成后 require('a') --> __webapck__require__("*/**/a.js"),这些都是 AST 的用途。

将代码转换成AST

一个对象生成 AST 的关键所在是词法分析和语法分析。对于JavaScript而言,可以通过JS ParserJS代码转换成AST,目前比较常见的JS Parser如下:

  • esprima(流行库)
  • Babylon(babel中使用)
  • acorn(webpack中使用)
  • espree(在acorn基础上衍生而来,eslint中使用)
  • astexplorer(在线生成工具,可选不同的JS Parser实时查看)

词法分析

JavaScript 编译执行流程:js 执行的第一步是读取 js 文件中的字符流,然后通过词法分析生成 token,之后再通过语法分析生成 AST(Abstract Syntax Tree),最后生成机器码执行。

词法分析指的是将对象逐个扫描(scanner),调用 next() 方法,一个字母一个字母的来读取字符,然后与定义好的 JavaScript 关键字符做比较,生成对应的 Token。所谓 Token 是最小的不可分割单元,像是下例中的 const 就已经无法被分割了,它就是一个 Token。类似 const 这样,每个关键字、标识符、操作符、标点符号等都是一个 Token,词法分析器会过滤掉源程序中的注释和空白字符(换行符、空格、制表符等)。最终词法分析会生成由对象组成的一维数组(Tokens列表)。

const a = 5;
//词法分析
[
  { value:'const', type:'keyword' },
  { value:'a', type:'identifier' }
  ...
]

每一个 type 有一组属性来描述该令牌:

{
  type: {
    label: 'name',
    keyword: undefined,
    beforeExpr: false,
    startsExpr: true,
    rightAssociative: false,
    isLoop: false,
    isAssign: false,
    prefix: false,
    postfix: false,
    binop: null,
    updateContext: null
  },
  ...
}

语法分析

语法分析指的是将有关联的对象整合成树形结构的表达形式。语法分析会将词法分析出来的 Token 转化成有语法含义的抽象语法树结构。同时,验证语法,语法如果有错的话,抛出语法错误。

const a = 5;
//语法分析
{
  "type": "Program",
  "start": 0,
  "end": 12,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 12,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 6,
          "end": 11,
          "id": {
            "type": "Identifier",
            "start": 6,
            "end": 7,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 10,
            "end": 11,
            "value": 5,
            "raw": "5"
          }
        }
      ],
      "kind": "const"
    }
  ],
  "sourceType": "module"
}

实践转换过程

在这一章节,我们站在巨人的肩膀上,使用esprima完成从 let a = 1;let b = 2; 的过程。

第一步,我们使用 astexplorer 观察一下 let a = 1; 对应的AST:

{
  "type": "Program",
  "start": 0,
  "end": 190,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 179,
      "end": 189,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 183,
          "end": 188,
          "id": {
            "type": "Identifier",
            "start": 183,
            "end": 184,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 187,
            "end": 188,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "let"
    }
  ],
  "sourceType": "module"
}

第二步,我们使用 astexplorer 观察一下 let b = 2; 对应的AST:

{
  "type": "Program",
  "start": 0,
  "end": 190,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 179,
      "end": 189,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 183,
          "end": 188,
          "id": {
            "type": "Identifier",
            "start": 183,
            "end": 184,
            "name": "b"
          },
          "init": {
            "type": "Literal",
            "start": 187,
            "end": 188,
            "value": 2,
            "raw": "2"
          }
        }
      ],
      "kind": "let"
    }
  ],
  "sourceType": "module"
}

第三步,对比一下发现前后不同在于typeIdentifieridname属性值不一样,以及typeLiteralinitvalue属性值不一样。接下来,我们需要安装estraverse(遍历AST)和escodegen(根据AST生成JS)这两个包,遍历AST树并修改 Node 属性,便可以完成转换过程。

const esprima = require('esprima');
const estraverse = require('estraverse');
const escodegen = require('escodegen');
​
const program = "let a = 1;";
const ASTree = esprima.parseScript(program);
​
function changeAToB(node) {
    if (node.type === 'Identifier') {
        node.name = 'b';
    }
    if (node.type === 'Literal') {
        node.value = 2
    }
}
​
estraverse.traverse(ASTree, {
    enter(node) {
        changeAToB(node);
    }
});
​
const ASTreeAfterChange = escodegen.generate(ASTree);
console.log(ASTreeAfterChange); // let b = 2;

常见的AST节点

Identifier

interface Identifier <: Expression, Pattern {
  type: "Identifier";
  name: string;
}

标识符,就是我们写 JS 时自定义的名称,如变量名,函数名,属性名,都归为标识符,值存放于字段name中。

ImportSpecifier

interface ImportSpecifier <: ModuleSpecifier {
  type: "ImportSpecifier";
  imported: Identifier;
}

单独导出的成员变量,描述的是例如 import { Message as MyMessage } from 'element-ui' 中的Message。 主要关注的是localimported字段,其都是 identitifier 类型,其 name 属性在没有进行别名处理时相同,如果使用了别名则 local 中的 name 则为别名,imported 为原始成员变量名。

// import { Message as MyMessage } from 'element-ui'
{
  "body": [
    {
      "specifiers": [
        {
          "imported": {
            "type": "Identifier",
            "name": "Message"  // imported 为原始成员变量名。
          },
          "local": {
            "type": "Identifier",
            "name": "MyMessage" // 使用了别名则 `local` 中的 `name` 则为别名
          }
        }
      ],
    }
  ],
}

ImportDefaultSpecifier

interface ImportDefaultSpecifier <: ModuleSpecifier {
  type: "ImportDefaultSpecifier";
}

描述的是形如import _ from "lodash"中的_

// import _ from "lodash"
{
  "body": [
    {
      "type": "ImportDeclaration",
      "specifiers": [
        {
          "type": "ImportDefaultSpecifier",    // type是ImportDefaultSpecifier
          "local": {
            "type": "Identifier",
            "name": "_"                                                 // 描述"_"
          }
        }
      ],
    }
  ],
}

CallExpression

interface CallExpression <: Expression {
  type: "CallExpression";
  callee: Expression | Super | Import;
  arguments: [ Expression | SpreadElement ];
}

函数调用表达式,比如:setTimeout(()=>{})callee 属性是一个表达式节点,表示函数,arguments 是一个数组,元素是表达式节点,表示函数参数列表。

// setTimeout(()=>{})
{
  "type": "Program",
  "body": [
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "CallExpression",
        "callee": { // 一个表达式节点,表示函数
          "type": "Identifier",
          "name": "setTimeout"
        },
        "arguments": [ // 数组,元素是表达式节点,表示函数参数列表
          {
            "type": "ArrowFunctionExpression",
            "params": [],
            "body": {
              "type": "BlockStatement",
              "body": []
            }
          }
        ],
        "optional": false
      }
    }
  ],
}

MemberExpression

interface MemberExpression <: Expression, Pattern {
  type: "MemberExpression";
  object: Expression | Super;
  property: Expression;
  computed: boolean;
}

成员表达式节点,即表示引用对象成员的语句,object是引用对象的表达式节点,property 是表示属性名称,computed 如果为 false,是表示 . 来引用成员,property 应该为一个 Identifier 节点,如果 computed 属性为 true,则是 [] 来进行引用,即 property 是一个 Expression 节点,名称是表达式的结果值

// window.setTimeout
{
  "type": "Program",
  "body": [
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "MemberExpression",
        "object": { // 引用对象的表达式节点
          "type": "Identifier",
          "name": "window"
        },
        "property": { // 表示属性名称
          "type": "Identifier",
          "name": "setTimeout"
        },
        "computed": false, // computed如果为 false,是表示 . 来引用成员,property 应该为一个 Identifier 节点,如果 computed 属性为 true,则是 [] 来进行引用,即 property 是一个 Expression 节点,名称是表达式的结果值
        "optional": false
      }
    }
  ],
}

AssignmentExpression

interface AssignmentExpression <: Expression {
  type: "AssignmentExpression";
  operator: AssignmentOperator;
  left: Pattern | Expression;
  right: Expression;
}

赋值表达式节点,operator 属性表示一个赋值运算符,leftright是赋值运算符左右的表达式。

// let a; a = 20;
{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a"
          },
          "init": null
        }
      ],
      "kind": "let"
    },
    {
      "type": "ExpressionStatement",
      "expression": {
        "type": "AssignmentExpression",
        "operator": "=",
        "left": {
          "type": "Identifier",
          "name": "a"
        },
        "right": {
          "type": "Literal",
          "value": 20,
          "raw": "20"
        }
      }
    }
  ],
}

ArrayExpression

interface ArrayExpression <: Expression {
  type: "ArrayExpression";
  elements: [ Expression | SpreadElement | null ];
}

数组表达式节点,const array = [d,2,3], elements 属性是一个数组,表示数组的多个元素,每一个元素都是一个表达式节点。

// const array = [d,2,3]
{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "array"
          },
          "init": {
            "type": "ArrayExpression",
            "elements": [ // 表示数组的多个元素,每一个元素都是一个表达式节点
              {
                "type": "Identifier",
                "name": "d"
              },
              {
                "type": "Literal",
                "value": 2,
                "raw": "2"
              },
              {
                "type": "Literal",
                "value": 3,
                "raw": "3"
              }
            ]
          }
        }
      ],
      "kind": "const"
    }
  ],
}

VariableDeclaration

interface VariableDeclaration <: Declaration {
  type: "VariableDeclaration";
  declarations: [ VariableDeclarator ];
  kind: "var" | "let" | "const";
}

变量声明表达式,kind 属性表示是什么类型的声明,值可能是var/const/letdeclarations表示声明的多个描述,因为我们可以这样:let a = 1, b = 2

// let a = 1, b = 2
{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
        },
        {
          "type": "VariableDeclarator",
        }
      ],
      "kind": "let"
    }
  ],
  "sourceType": "module"
}

LogicalExpression

interface LogicalExpression <: Expression {
  type: "LogicalExpression";
  operator: LogicalOperator;
  left: Expression;
  right: Expression;
}

逻辑运算表达式, || 或者 &&

ConditionalExpression

interface ConditionalExpression <: Expression {
  type: "ConditionalExpression";
  test: Expression;
  alternate: Expression;
  consequent: Expression;
}

条件表达式即三元运算表达式:boolean ? true : false

IfStatement

interface IfStatement <: Statement {
  type: "IfStatement";
  test: Expression;
  consequent: Statement;
  alternate: Statement | null;
}

if 表达式,if(true)test 属性表示 if (...) 括号中的表达式。 consequent 属性是表示条件为 true 时的执行语句,通常会是一个块语句。 alternate 属性则是用来表示 else 后跟随的语句节点,通常也会是块语句,但也可以又是一个 if 语句节点,即类似这样的结构:if (a) { ... } else if (b) { ... }alternate 当然也可以为 null

ESLint简要介绍

ESLintJavaScript 红宝书 作者 Nicholas C. Zakas 编写, 2013 年发布第一个版本。ESLint 是以可扩展、每条规则独立、不内置编码风格为理念编写的一个 lint 工具。

ESLint 主要有以下特点:

  • 默认规则包含所有 JSLint、JSHint 中存在的规则,易迁移;
  • 规则可配置性高:可设置「警告」、「错误」两个 error 等级,或者直接禁用;
  • 包含代码风格检测的规则
  • 支持插件扩展、自定义规则

ESLint 详尽使用参见 官方文档

ESLint 的核心在于约束,通过各种规则 rule 进行约束。JS是一个随心所欲的动态语言,我们需要各类规则约束代码,使其可读性更高,代码更健壮,工程更可靠。规则的本质就是前人的经验总结,将经验抽象为规则使团队成员共享最佳实践。

ESLint基本配置

我们通常在 eslintrc 文件中配置 eslint,其中包括以下这些配置项。

parser

功能:将代码转换为 eslint 能理解的 AST 语法树,支持我们对非标准 JS 语法添加 Lint

parser 是解析器,其功能对应编译原理中的词法分析、语法分析。如果需要其他功能,则可以使用额外的 parse 配置,如解析 TS 代码时使用 typescript-eslint/parser,使用了 babel 时使用 @babel/eslint-parser 等。

module.exports = {
    "parser": "@typescript-eslint/parser",
}

parserOptions

对于解析器的配置。常用的属性有以下几个:

  • ecmaVersion: latest: 配置代码的 js 版本,告知 ESLint 我们想要支持什么版本的 JS 语法
  • sourceType: 'module': 源码类型,是否允许使用export / import
  • ecmaFeatures: { jsx: true }:告知 ESLint 是否使用 jsx

rules

一条 rule 就是单独的一条规则,针对一个具体问题,例如不允许使用 console.time()

{
    "rules": {
        "semi": ["error", "always"],
        "quotes": ["error", "double"]
    }
}

semiquotesrules 的名字,第一个值是规则的错误等级,有一下三类:

  • "off"0 - 关闭规则
  • "warn"1 - 将规则视为警告
  • "error"2 - 将规则视为错误

三个错误等级让我们可以方便控制 ESLint 如何应用这些规则,此外还可以传入 options,用于灵活的配置规则,本文将会编写的规则便需要自定义 options

我们在 .eslintrc.* 中配置的 rules 字段是规则,我们在代码中写的注释,例如 // eslint-disable-next-line @typescript-eslint/no-unused-vars 也是规则,两部分合并起来得到了最终的 rule

得到了最终 rule 之后,深度优先遍历源码生成的 AST,将 node 存放到 ESLint 内部维护的队列中,然后遍历所有规则,为规则中所有的选择器添加监听事件,在触发时执行,触发队列中包含的事件就会返回显示给用户的 lintingProblems。在 ESLint 中应用 rule 是一个事件驱动的好范例。

plugins

针对特殊语法自定义的那些规则我们称之为eslint 插件,插件为一系列规则 rules 的合集,一般作为一个 plugin,如一个 plugin 是用来规范变量命名的,那么它可能包含对于普通变量的 rule,对于导出常量的 rule,对于组件命名的 rule,对于样式命名的 rule 等等。

常见的插件有: eslint-plugin-importeslint-plugin-promiseeslint-plugin-react。ESLint 本身规则只会去支持标准的 ECMAScript 语法,如果我们想在 React 中也使用 ESLint 则需要自己去定义一些规则,就有了 eslint-plugin-react

extends

一系列 plugins 的合集,如 Google 的 gts。根据要求,extends 的命名都以eslint-config-为开头,在配置 extends 时可以省略这个前缀。引入的 extends 可以是 npm 包,也可以是本地路径。plugins 的配置仅仅代表在项目中引入了哪些规则,并没有指明该规则是警告、报错、忽略,extends 要做的就是引入 eslint 推荐的规则设置。按照 eslint 插件的开发规范,每个插件的入口文件都会导出一个对象,其中就有一个 configs 字段,该字段是个对象,他可以把该插件已有的规则分成不同的风格。

/** .eslintrc.js */
module.exports = {
    // eslint-config-airbnb https://www.npmjs.com/package/eslint-config-airbnb
    "extends": "airbnb"   
};

Airbnb JavaScript Style Guide 是最出名的社区实践之一,上述 airbnbeslint-config-airbng 的缩写,这里的意思是让 ESLinteslint-config-airbng 的规则做为拓展引用到我们自己的项目中来。

overrides

若要对某些文件进行更细致的定制化,则在overrides字段中进行配置。

settings

用于配置全局共享的设置,官方文档

env

设置代码的运行环境,如 node 环境还是 browser 环境。在设置 env 后可以使用对应的全局变量。 除了预定义的字段外,也可以开启某个 plugin 中的某种环境

    "env": {
        "browser": true,  // 可以使用window
        "es6": true,      // 可以使用Map、Set等新的数据类型,并自动开启es6语法
        "node": true,     // 可以使用global
    },

编写自己的ESLint插件

此插件的目的在于对 import 项分组整理,达成以下效果:

// react
import React, {useEffect} from 'react';
// style
import styles from './index.module.scss'
// layout
import Footer from "@layout/footer";
import Header from "@layout/header";

开发过程

新建项目

我们使用官方推荐的 Yeoman generator 开发插件,依次运行以下指令

npm i -g yo
npm i -g generator-eslint
yo eslint:plugin  // create a new ESLint plugin, make sure you're in the top-level directory
yo eslint:rule    // create a new ESLint rule, make sure you're in the top-level directory

运行完上述指令后,我们会得到如下所示的文件树

.
├── README.md
├── docs
│   └── rules
│       └── import-order-demo005.md
├── lib
│   ├── index.js                             // 此处将规则进行导出
│   └── rules
│       └── import-order-demo005.js          // 这里是我们的规则主体内容
├── package-lock.json
├── package.json
└── tests
    └── lib
        └── rules
            └── import-order-demo005.js    // 在此处编写我们的测试用例

编写自定义规则

解析context对象需要用到 eslint 提供的多个方法,我们主要参考官方中文文档:cn.eslint.org/docs/develo…,具体涉及到的方法用途写在了代码注释中。working-with-plugins working-with-rules 这两篇官方文档能够帮助我们更好地理解如何编写自定义规则。

// lib/rules/eslint-plugin-group-import-decorations.js
/**
 * @fileoverview Group import decorations in React projects
 * @author fl427
 * eslint开发方法集合:https://cn.eslint.org/docs/developer-guide/working-with-rules
 */
"use strict";
​
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
  meta: {
    type: 'suggestion', // `problem`, `suggestion`, or `layout`
    docs: {
      description: "Group import decorations in React projects",
      recommended: false,
      url: null, // URL to the documentation page for this rule
    },
    fixable: 'code',
    schema: [{
      type: 'object',
    }],
    messages: {
      sort: '运行ESLint规则来整理import分组',
      nameComment: '分组注释错误',
      intervalComment: '同组的节点之间存在多余注释'
    },
  },
​
  create(context) {
    // 拿到配置对象,对象的key即为组名(ex: 'react'),对象的value即为该组对应正则(ex: '^react')
    const options = context.options[0] || {
      "react": "^react",
      "style": ".(css|scss|less|sass)$",
      "common": ".(png|jpg|jpeg|svg|gif|json)$",
      "hooks":"/hooks/",
      "src": "@src/",
    };
    const groups = [];
    // 遍历配置对象,构造分组数组
    for (let name in options) {
      const reg = options[name];
      groups.push({
        name,
        reg,
        imports: []
      })
    }
​
    // 用户没有指定other则将无法分类的import项统一放入other分组中
    if (!options['other']) {
      groups.push({
        name: 'other',
        rules: '.*',
        imports: [],
      })
    }
​
    // group前注释错误
    function isGroupNameError(node, name) {
      // getSourceCode返回一个SourceCode对象,可以使用该对象处理传递给 ESLint 的源代码。
      const sourceCode = context.getSourceCode();
      // getCommentsBefore返回一个在给定的节点或 token 之前的注释的数组
      const commentsBefore = sourceCode.getCommentsBefore(node);
      // 没有注释 或者 最近的一行注释和指定的group名字不匹配
      return {
        // 没有注释
        noNameComment: !commentsBefore.length,
        // 有注释但注释错误
        groupNameIsError: commentsBefore.length && commentsBefore[commentsBefore.length - 1].value !== ` ${name}`
      }
    }
​
    return {
      Program: programNode => {
        // SourceCode 是获取被检查源码的更多信息的主要对象。可以使用 getSourceCode()在任何时间检索 SourceCode 对象。
        const sourceCode = context.getSourceCode();
        // importNodes存放所有的import node
        const importNodes = [];
        // 将import node放到正则匹配的数组中
        for (const node of programNode.body) {
          if (node.type === 'ImportDeclaration') {
            // 是引用语句
            importNodes.push(node);
            // 遍历每个分组
            for (let group of groups) {
              const regex = new RegExp(group.reg, 'ig');
              // 这个node符合该rule,放到对应的数组中
              if (regex.test(node.source.value)) {
                if (Boolean(group.imports) === false) {
                  group.imports = [];
                }
                group.imports.push({
                  // 每次推入一个node,group.imports的长度就+1,我们将当前node的序号记录下来,方便后续判断哪一个import node是当前组的第一个,从而添加组前注释
                  idx: group.imports.length,
                  name: group.name,
                  node,
                });
                break;
              }
            }
          }
        }
​
        // 平铺存放按顺序排好的{idx, name, node}结构体,后续与原始的import nodes对比
        let orderImport = [];
        for (let group of groups) {
          orderImport = orderImport.concat(group.imports);
        }
​
        // 遍历所有的import nodes,利用fixer让import nodes的顺序与整理好的group-imports一致(通过替换文本实现)
        for (let index = 0; index < importNodes.length; index++) {
          // 原始node
          const node = importNodes[index];
          const prevNode = importNodes[index - 1];
          // {idx, name, node}结构体
          const orderedNode = orderImport[index];
          const prevOrderedNode = orderImport[index - 1];
          // getText返回给定节点的源码。省略 node,返回整个源码。
          const nowText = sourceCode.getText(node);
          const orderedNodeText = sourceCode.getText(orderedNode.node);
          if (nowText !== orderedNodeText) {
            context.report({
              node,
              messageId: 'sort',
              // replaceText替换给定的节点或记号内的文本
              fix: fixer => fixer.replaceText(node, orderedNodeText),
            });
          } else {
            // 是该组的第一个节点,判断这个节点的注释是否正确,不正确则在前面插入注释
            const { noNameComment, groupNameIsError } = isGroupNameError(orderedNode.node, orderedNode.name)
            // 是该组的第一个节点,没有注释或者注释错误都要插入一行注释
            const isFirstNodeAndCommentErr = orderedNode.idx === 0 && (noNameComment || groupNameIsError);
            // 不是第一个节点,但是它之前有注释且注释错误,需要插入一行注释来纠正错误,我们不会在没有注释的情况下插入注释,因为我们只希望注释放在第一个节点前面
            const otherNodeAndCommentErr = orderedNode.idx != 0 && groupNameIsError
            if (isFirstNodeAndCommentErr || otherNodeAndCommentErr) {
              context.report({
                node,
                messageId: 'nameComment',
                // insertTextBeforeRange在给定的节点或记号之前插入文本
                fix: fixer => fixer.insertTextBeforeRange(node.range, `// ${orderedNode.name}\n`),
              });
            }
​
            // 如果前后两个node是同一组,而它们之间存在注释,则去除注释
            if (prevNode && prevOrderedNode && sourceCode.commentsExistBetween(prevNode, node) && prevOrderedNode.name === orderedNode.name) {
              context.report({
                node,
                messageId: 'intervalComment',
                // removeRange删除给定范围内的文本
                fix: fixer => fixer.removeRange([prevNode.range[1] + 1, node.range[0] - 1]),
              });
            }
          }
        }
      }
    };
  },
};

编写测试用例

 'use strict';
 /**
 * @fileoverview import order
 * @author fl427
 */
 'use strict';
 const rule = require("../../../lib/rules/eslint-plugin-group-import-decorations")
 const { RuleTester } = require('eslint');
 const ruleTester = new RuleTester({
   parserOptions: {
     ecmaVersion: 2020,
     sourceType: 'module',
   },
 });
 ruleTester.run('eslint-plugin-group-import-decorations', rule, {
  valid: [
    {
      code: 
      `
          // react
          import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
          import { useLocation } from 'react-router-dom';
          // style
          import './index.scss';
          // hooks
          import useInterval from 'src/hooks/useInterval';
          // common
          import noneImg from '../../common/imgs/empty.png';
      `, 
        errors: 1,
        options: [{
          "react": "^react",
          "style": ".(css|scss|less|sass)$",
          "src": "@src/",
          "common": ".(png|jpg|jpeg|svg|gif|json)$",
          "hooks":"/hooks/",
        }],
      }
    ],
  invalid: [
     {
      code: `
      import React, { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
      import { useLocation } from 'react-router-dom';
      import './index.scss';
      `,
      errors: 1,
      options: [{
        "react": "^react",
        "style": ".(css|scss|less|sass)$",
        "src": "@src/",
        "common": ".(png|jpg|jpeg|svg|gif|json)$",
        "hooks":"/hooks/",
      }],
     },
   ],
 });

导出并发布

我们的插件至此开发完成,接下来需要编写对 eslint 暴露这个模块的代码。

// lib/index.js
/**
 * @fileoverview Group Import Decorations in React Projects
 * @author fl427
 */
"use strict";
const requireIndex = require("requireindex");
// import all rules in lib/rules
module.exports.rules = requireIndex(__dirname + "/rules");
​
module.exports  = {
    rules: {
        'group-import-decorations': require('./rules/eslint-plugin-group-import-decorations')
    },
    configs: {
        recommended: {
            rules: {
                'group-import-decorations/group-import-decorations': 2
            }
        }
    }
}

完成了对于模块的导出之后,我们将其发布到 npm 方便使用。

npm config set registry https://registry.npmjs.org/      // 修改registry,确保我们的npm源为官方源
npm login                                                                                                 // 发布npm包需要是登录状态
npm publish                                                                                             // 发布npm包

至此整个流程发布完毕。

项目中引用

我们编写的是 ESLint 插件,所以为了让其生效,我们首先需要在 VSCode 或者 WebStorm 中启用对 ESLint的支持,对 VSCode,我们需要安装 ESLint 插件,而 WebStorm 内置了 ESLint ,我们在 Preference 中搜索 eslint 并打开即可。

demo-eslint-webstorm-001

devDependencies 装好 npm 包之后在 .eslintrc.js 文件中配置如下内容,指定我们的 rules

// .eslintrc.js
module.exports = {
    "env": {
        "browser": true,
        "es2021": true,
        "node": true,
    },
    "extends": [
        "eslint:recommended",
        "plugin:react/recommended",
        "plugin:@typescript-eslint/recommended",
        "plugin:group-import-decorations/recommended"
    ],
    "overrides": [
    ],
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
        "ecmaVersion": "latest",
        "sourceType": "module"
    },
    "plugins": [
        "react",
        "@typescript-eslint",
        'import',
        "group-import-decorations",
    ],
    // group-import-decorations/group-import-decorations,前面的是我们的plugin的名字,后面的是module.exports的rules的key值
    "rules": {
        '@typescript-eslint/no-var-requires': 0,
        "group-import-decorations/group-import-decorations": [
            2,
            {
                "react": "^react",
                "style": ".(css|scss|less|sass)$",
                "layout": "@layout/",
                "pages": "@pages/",
                "common": ".(png|jpg|jpeg|svg|gif|json)$",
                "hooks":"/hooks/",
            }
        ],
    }
}

配套源码地址

npm

www.npmjs.com/package/esl…

github

github.com/fl427/eslin…

参考

Working with Rules - ESLint - Pluggable JavaScript Linter

Working with Plugins - ESLint - Pluggable JavaScript Linter

Working with Rules

eslint插件开发教程

一文助你搞懂 AST且听风歌的技术博客51CTO博客

AST抽象语法树 - 简书

AST节点介绍 - 简书

www.goyth.com/2018/12/23/…

Astexplorer: 一个web工具,用于研究由各种解析器生成的AST。

什么叫AST抽象语法树?

Eslint 核心概念 & 自定义 plugin 开发

ESLint 工作原理探讨

Eslint 的 fix 功能是怎么实现的