【 ESLint 插件】修改 Import 顺序的工具

1,331 阅读18分钟

这里要先说🤏大佬不感兴趣的废话,如果对 ESLint 比较熟悉建议跳过1、2章节。

一. ESLint能做什么:

  • 发现问题: ESLint 静态分析您的代码以快速发现问题。ESLint 内置于大多数文本编辑器中,您可以将 ESLint 作为持续集成管道的一部分运行。
  • 自动修复: ESLint 发现的许多问题都可以自动修复的。ESLint 修复语法感知的来保证您不会遇到传统查找和替换算法引入的错误。
  • 可定制化: 预处理代码,使用自定义解析器,并编写您自己的与 ESLint 的内置规则一起工作的规则。您可以自定义 ESLint 以完全按照您的项目所需的方式工作。

ESLint 旨在为您的用例提供灵活和可配置的设计。您可以关闭每个规则并仅在基本语法验证的情况下运行或者混合并匹配捆绑的规则和您的自定义规则以满足您的项目需求。配置 ESLint 有两种主要方法:

  1. 配置注释- 使用 JavaScript 注释将配置信息直接嵌入到文件中。

  2. 配置文件- 使用 JavaScript、JSON 或 YAML 文件来指定整个目录及其所有子目录的配置信息。这可以是.eslintrc.*文件或文件中的 eslintConfig 字段的形式,ESLint 都会自动查找和读取这两种形式,也可以在命令行package.json中指定配置文件。

以下是您可以在 ESLint 中配置的一些选项:

  • Environments - 您运行脚本设计的环境。每个环境都带有一组特定的预定义全局变量。

  • Globals - 您的脚本在执行期间访问的其他全局变量。

  • Rules - 启用的规则及其各自的错误级别。

  • Plugins - 第三方插件为 ESLint 定义了额外的规则、环境、配置等,在这里我们可以做一些自由度比较高的插件。

二. 需要了解的简单配置:

指定解析器选项(parserOptions)

ESLint 允许你指定你想要支持的 JavaScript 语言选项。默认情况下,ESLint 需要 ECMAScript 5 语法。您可以使用解析器选项覆盖该设置以启用对其他 ECMAScript 版本以及 JSX 的支持。

  • 对于 ES6 语法,使用 { "parserOptions": { "ecmaVersion": 6 } }

  • 对于新的 ES6 全局变量,使用 { "env": { "es6": true } }

  • { "env": { "es6": true } } 自动启用 ES6 语法,但 { "parserOptions": { "ecmaVersion": 6 } } 不会自动启用 ES6 全局变量。

  • sourceType- 设置为 "script"(默认)您的代码在 ECMAScript 模块中可以设置 "module"。

  • ecmaFeatures- 一个对象,表达您想使用哪些附加语言功能:

  • globalReturn- 允许return全局范围内的语句

  • impliedStrict- 启用全局严格模式

  • jsx- 启用JSX

指定解析器(Parser)

默认情况下,ESLint 使用 Espree 作为其解析器。您可以在配置文件中使用不同的解析器,只要解析器满足以下要求:

  1. 必须是一个 Node 模块,可以从它出现的配置文件中加载。通常,这意味着应该使用 npm 单独安装解析器包

  2. 它必须符合 parser interface

要指示要用作解析器的 npm 模块,请使用 .eslintr 文件中的 parser 选项指定它。例如,以下指定使用 Esprima 解析器而不是 Espree:

{
    "parser": "esprima",
    "rules": {
        "semi": "error"
    }
}

指定处理器(processor)

一些插件也可以提供处理器。这些处理器可以从其他类型的文件中提取 JavaScript 代码,然后让 ESLint 对 JavaScript 代码进行 lint,或者处理器可以出于某种目的在预处理中转换 JavaScript 代码。

下面的选项启用插件 a-plugin 提供的处理器 a-processor:

{
    "plugins": ["a-plugin"],
    "processor": "a-plugin/a-processor"
}

要为特定类型的文件指定处理器,请使用 overrides 键和 processor 键的组合。例如,下面对 *.md 文件使用处理器 a-plugin/markdown。

{
    "plugins": ["a-plugin"],
    "overrides": [
        {
            "files": ["*.md"],
            "processor": "a-plugin/markdown"
        }
    ]
}

配置规则(rule)

ESLint 内置了大量规则,您可以通过插件添加更多规则。您可以使用配置注释或配置文件来修改项目使用的规则。要更改规则设置,您必须将规则 ID 设置为以下值之一:

  • "off"或0- 🙅关闭规则
  • "warn"或1- ⚠️警告规则
  • "error"或2- ❌错误规则
{
    "rules": {
        "eqeqeq": "off",
        "curly": "error",
        "quotes": ["error", "double"]
    }
}

Vue + Ts 项目中常用配置

使用eslint:recommended

"eslint:recommended"在属性中使用extends可启用报告常见问题的核心规则子集(具体规则信息参考这里

module.exports = {
    "extends": "eslint:recommended",
    "rules": {
        // enable additional rules
        "indent": ["error", 4],
        "linebreak-style": ["error", "unix"],
        "quotes": ["error", "double"],
        "semi": ["error", "always"],

        // override configuration set by extending "eslint:recommended"
        "no-empty": "warn",
        "no-cond-assign": ["error", "always"],

        // disable rules from base configurations
         "for-direction": "off",
    }
}

Vue + TS 配置

如果你想使用自定义解析器(vue项目基本是必须的),例如 @babel/eslint-parser@typescript-eslint/parser ,您必须使用 parserOptions.parser 选项而不是 parser 选项。因为这个插件需要 vue-eslint-parser 要解析文件,如果您覆盖该选项,此插件将不起作用。

"parser": "vue-eslint-parser",
"parserOptions": {
  "parser": "@typescript-eslint/parser",
  "sourceType": "module"
}

更详细的 vue-eslint-parser 信息可以参考这里

一个可运行的的 vue2 项目 eslint 配置可以参考如下⬇️

// .eslintrc.js
{
  env: {
    browser: true,
    es2021: true, // ES2021 全局变量
  },
  extends: [
    'eslint:recommended', // eslint核心规则
    'plugin:vue/essential', // vue基础配置,防止错误或意外行为的规则
    "plugin:vue/strongly-recommended", // 可选 完全包含essential,并加上加上大大提高代码可读性和/或开发体验的规则。
    "plugin:vue/recommended", // 可选 完全包含strongly-recommended,加上强制执行主观社区默认值以确保一致性的规则
    'plugin:@typescript-eslint/recommended', // 是我们的“推荐”配置——它就像eslint:recommended,它只打开来自我们特定于 TypeScript 的插件的规则。
  ],
  // vue-eslint 解析器,这个解析器允许我们对<template>文件进行 lint .vue。
  // <template>如果我们在模板中使用复杂的指令和表达式,我们很容易出错。
  // 这个解析器和eslint-plugin-vue的规则会捕捉到一些错误。
  parser: 'vue-eslint-parser', 
  parserOptions: {
    ecmaVersion: 12, //  ES12 语法
    // 这允许 ESLint 理解 TypeScript 语法。
		// 这是必需的,否则 ESLint 会在尝试解析 TypeScript 代码时抛出错误,就像它是常规 JavaScript 一样。
    parser: '@typescript-eslint/parser',
    sourceType: 'module',
  },
  rules: {
    // ....
}
// .eslintignore ts + vue 方案
**/node_modules/*
**/dist/*
**/tests/*
**/*.js
types.d.ts
shims-tsx.d.ts
shims-vue.d.ts
  1. ts其他自定义配置 & prettier配合使用可以参考这里

AlloyTeam eslint config

这里推荐一下我的 ESLint 配置方式,我主要参考 Alloy 团队提供的 ESLint 配置,Alloy 提供的文档甚至可以直接在网站中看到 bad.js 的(真实运行 ESLint 脚本后的)报错信息。

设计理念:
  • 样式相关的规则交给 Prettier 管理, 推荐的 Prettier 配置可以参考这里
  • 传承 ESLint 的理念,帮助大家建立自己的规则
  • 高度的自动化:先进的规则管理,测试即文档即网站
  • 与时俱进,第一时间跟进最新的规则,eslint-config-alloy 通过上述的自动化工具,可以在第一时间就收到 GitHub Actions 的通知,告诉我们哪些规则需要添加
使用方法:
npm install --save-dev eslint @babel/eslint-parser vue-eslint-parser eslint-plugin-vue eslint-config-alloy
module.exports = {
  extends: [
    'alloy',
    'alloy/vue',
  ],
  env: {
    // 你的环境变量(包含多个预定义的全局变量)
    //
    // browser: true,
    // node: true,
    // mocha: true,
    // jest: true,
    // jquery: true
  },
  globals: {
    // 你的全局变量(设置为 false 表示它不允许被重新赋值)
    //
    // myGlobal: false
  },
  rules: {
    // 自定义你的规则
  },
};
具体配置信息:

在文档中我们可以快速了解配置信息的正反例子

其他配置

具体可以参考官网配置

三. 需要了解的相关知识:

这里逐条按照文档来补充知识就可以了

1. ESLint 使用 AST 来评估代码中的模式

现代 JS parser 的 AST 大多是 estree 标准,estree 其实是来自于 SpiderMonkey 标准,这里来具体了解一下 estree:

Once upon a time, an unsuspecting Mozilla engineer created an API in Firefox that exposed the SpiderMonkey engine's JavaScript parser as a JavaScript API. Said engineer documented the format it produced, and this format caught on as a lingua franca for tools that manipulate JavaScript source code. Meanwhile JavaScript is evolving. This site will serve as a community standard for people involved in building and using these tools to help evolve this format to keep up with the evolution of the JavaScript language.

AST 是对源码的抽象,字面量、标识符、表达式、语句、模块语法、class 语法都有各自的 AST。estree的结构目前依旧每年都在更新,最新的 es2022 标准可以参考这里

通过 AST 结构将代码抽象成树状数据结构,方便后续分析检测代码。我们可以用 astexplorer.net 它能查看代码被解析成AST的样子。这个工具也是后续自定义插件需要参考的重要工具。

Identifier 标识符变量名、属性名、参数名等各种声明和引用的名字
Literal 字面量
Programs 完整源码树: 入口
Declaration 声明节点。声明可以出现在任何语句上下文中。FunctionDeclaration函数声明 id不为空
VariableDeclaration变量声明
Expression 表达式节点。由于赋值的左侧通常可以是任何表达式,因此 Expression 也可以是 pattern。ThisExpressionthis
ArrayExpression[1,2,3]
ObjectExpression{a: 1}
FunctionExpressionfunction(){}
AssignmentExpressiona = 1
BinaryExpressiona + 1
UpdateExpressiona++
LogicalExpressionab
MemberExpressiona[b]
ConditionalExpressiona ? 1 : 2
NewExpressionnew a
Statement 语句ExpressionStatementa = 2
BlockStatement{}
EmptyStatement
WithStatementwith()
ReturnStatementreturn
LabeledStatementa: 1
BreakStatementbreak
ContinueStatementcontinue
IfStatementif
SwitchStatementswitch
ThrowStatementthrow
TryStatementtry{}
WhileStatementwhile (true) {}
ForStatementfor (let i = 0;i < 10;i ++) {}

大概就是上面这些,感兴趣可以去estree官方文档查看详细信息。

有关ast的信息可以到此为止,下一章会对 ECMAScript 结构做一些补充。

有关ECMAScript

有关 estree 标准 也不是空穴来风,作为一种非官法的语法表达标准,其中的表达式或语句的来源其实是来自 ECMA规范 中的定义。

在这里可以抛砖引玉一下,通过解读 estee 结构可以让我们更深入的理解 JS 规范,这也是学习 AST 的价值,不单单只是为了学习如何去写 ESLint 或者 Babel,事实上了解这种 AST 结构以及 parse - transform - generate 的思考过程能让我们更加清楚的了解一些ECMA的底层逻辑。

这里先抛出一个经典例子:

var a = {};
var b = a;
a.b = a = 1;
// a : 1, b : { b: 1}

需要注意的是解读这种赋值表达式离不开 Left-Hand-Side Expressions 这个定义,不过首先,我们要先了解什么是赋值表达式,以下是赋值表达式定义,相信我了解这些不会吃亏⬇️:

Syntax
AssignmentExpression[In, Yield, Await]:
	(条件表达式)ConditionalExpression[?In, ?Yield, ?Await] 
	[+Yield]YieldExpression[?In, ?Await]
	ArrowFunction[?In, ?Yield, ?Await]
	AsyncArrowFunction[?In, ?Yield, ?Await]
	(左值表达式)LeftHandSideExpression[?Yield, ?Await]=AssignmentExpression[?In, ?Yield, ?Await]
	(左值表达式)LeftHandSideExpression[?Yield, ?Await]AssignmentOperator AssignmentExpression[?In, ?Yield, ?Await]

AssignmentOperator:one of
		*=/=%=+=-=<<=>>=>>>=&=^=|=**=

赋值表达式里最重要的其实是这一行:

LeftHandSideExpression = AssignmentExpression

这就定义了赋值表达式可以以递归的形式不断在右侧增加 LeftHandSideExpression

最后形成多等号表达式:

LeftHandSideExpression = LeftHandSideExpression = LeftHandSideExpression = LeftHandSideExpression = AssignmentExpression

这也是我们可以写 a.b = a = 1; 的逻辑依据,这里需要确定的是等号左右两侧都允许什么样的值存在,为什么左侧可以写 a.b 和 a,这里要说 LeftHandSideExpression 以及其它表达式的具体意义以方便第一次看的同学理解:

那接下来要看 LeftHandSideExpression 的具体定义

LeftHandSideExpression[Yield, Await]:
    NewExpression[?Yield, ?Await]
    CallExpression[?Yield, ?Await]

这不一下就明白了 LeftHandSideExpression 是 NewExpression 和 CallExpression 的集合。

这就比较有意思了,事实上 JS 允许我们使用 a() = b 的语法规则,只是函数返回值无法赋值才导致他没有意义,不过这就使得我们可以使用 ⬇️

var c = a().b = 3
// new Expression = Call Expression = Assignment Expression、
// 满足 LeftHandSideExpression = LeftHandSideExpression = AssignmentExpression

在这里,我们才终于解释得通 a.b = a = 1; 这种连续赋值运算存在的依据,接下来我们要看的是运算规则,这条代码到底如何解读,这里可以参考的是 12.15.4Runtime Semantics: Evaluation

  1. Let lref be the result of evaluating LeftHandSideExpression.
  2. ReturnIfAbrupt(lref).
  3. Let rref be the result of evaluating AssignmentExpression.
  4. Let rval be GetValue(rref).f. Let status be PutValue(lref, rval).
  5. ReturnIfAbrupt(status).
  6. Return rval.

按照这个思路,我们要先取到左侧 LeftHandSideExpression 的 lref,再获取右侧的 AssignmentExpression 作为 rref,然后我们要把 rref 的 rval 赋值给 fref,然后返回 lref 的值。
这里如果是多个等号连接的情况下,就要依次从左往右获取 rref,再从右往左把 rval 的值还给 lref。如果赋值存在问题,就返回 rval。

好了这基本就可以解释上面的问题了

var a = {};
var b = a;
a.b = a = 1;
  • 首先从左往右获取ref a.b的ref => a的ref

  • 然后从右往左赋值,a.b (可以看做 {b: undefined}) = a (可以看做{}) <= 1;

    • 我们把 1 赋值给 a 的 ref 也就是 {},所以 a = 1, return 1
    • 我们把 1 赋值给 a.b 也就变成了 {b: 1} 注意我们虽然在之前已经改变了 a 的指向,但是 a.b 的指向是不会变的因为我们在第一步就已经获取到了 lref
  • 最后a.b = a = 1; 会输出1

好了这就是一个学习ECMA标准的例子,接下来进行巩固训练

var a=1
function A(){
    a={i:0,c:2}
    return a
}
var b={c}=(A()).i=a.d={c: 100}
// a: {c: 2 d: {c: 100} i: {c: 100}}
// b: {c: 100}
// c: 100

2. ESLint 使用Espree进行 parse。

Espree started out as a fork of Esprima v1.2.2, the last stable published released of Esprima before work on ECMAScript 6 began. Espree is now built on top of Acorn, which has a modular architecture that allows extension of core functionality. The goal of Espree is to produce output that is similar to Esprima with a similar API so that it can be used in place of Esprima.

ESLint从一开始就依赖 Esprima 作为其解析器。当 Esprima 与 JS 语言同步发展的时候没有什么问题出现,但现在 JS 的发展激烈变化,ESprima 却跟不上进度,所以 ESLint 决定创建自己的解析器 Espree,起初的 Espree 是 fork 的 Esprima 并做一些改动,使得 Eslint 可以在需要时继续实现新功能。

在 Espree 2.0.0 中,Espree 不再是 Esprima 的一个分支,而是 Acorn 和 Esprima 语法之间的翻译层。这使我们 ESLint 能够将工作投入到社区支持的解析器 (Acorn) 中,该解析器将继续发展,同时为仍然构建在 Esprima 上的那些实用程序维护一个与 Esprima 兼容的解析器。

需要明白的 Eslint 实现原理

git地址: github.com/eslint/esli…

在这里我是直接通过 npm run fix 启动官方的 Makefile 来走测试流程

首先要说一下大致的文件有个印象

  • bin/eslint.js- 这是使用命令行实用程序实际执行的文件。它只是引导 ESLint,将命令行参数传递给 cli。

  • lib/api.js- 这是 eslint 的入口点。该文件包含公共类 Linter、ESLint、RuleTester 和SourceCode这几个方法。

  • lib/cli.js- 这是 ESLint CLI 的核心。它接受一组参数用于 eslint 执行命令。主要调用是cli.execute()。这也是执行所有文件的读取、目录遍历、输入和输出的部分。

  • lib/cli-engine- 这是基于 Linter 类的上层封装,该模块有文件解析器、插件和格式化程序的加载逻辑。

  • lib/linter- 该模块是 Linter 基于配置选项进行代码验证的核心类。

  • lib/rules- 这包含验证源代码的内置规则。

image.png 首先要说明的是 ESlint 本质上与 webpack 这种打包工具相似,本身并不提供实质上的修改,源代码的修改都是通过配置选项来做到的。

step1: 初始化 - parse

  • 代码开始执行首先是从 bin/eslint 里进入到 lint/cli/execute 函数中,在这里来做 ESLint 的实例化和传入文件的正确性验证

  • 之后代码就会进入到了 lib/linter/linter 文件,verifyAndFix 方法中对文本执行多次自动修复

verifyAndFix(text, config, options) {
    let messages = [], fixedResult, fixed = false, passNumber = 0, currentText = text;
    const shouldFix = options && typeof options.fix !== "undefined" ? options.fix : true;
    do {
        passNumber++;
        // 执行验证流程
        messages = this.verify(currentText, config, options);
        // 执行修复流程
        fixedResult = SourceCodeFixer.applyFixes(currentText, messages, shouldFix);
        if (messages.length === 1 && messages[0].fatal) {
            break;
        }
        fixed = fixed || fixedResult.fixed;
        currentText = fixedResult.output;
    } while (
      // 带有--fix的开始多次校验执行验证修复流程
        fixedResult.fixed && passNumber < 10
    );

    if (fixedResult.fixed) {
        fixedResult.messages = this.verify(currentText, config, options);
    }
    // 确保最后文件修复完成
    fixedResult.fixed = fixed;
    fixedResult.output = currentText;
    return fixedResult;
}
  • 这里会进入 this.verify(currentText, config, options); 文件,

  • verify 方法中会根据定义的规则中是否有 preprocesspostprocess 来决定是否调用_verifyWithProcessor

    • preprocess 处理的是非 js 文件,将其他命名规范的文件转换成 js 文件。
    • postprocess 处理 problems 和 message,来过滤掉输出信息。
  • 如果没有定于这些预处理,就会直接调用 _verifyWithoutProcessors

verify(textOrSourceCode, config, filenameOrOptions) {
    if (options.preprocess || options.postprocess) {
        return this._distinguishSuppressedMessages(this._verifyWithProcessor(textOrSourceCode, config, options));
    }
    return this._distinguishSuppressedMessages(this._verifyWithoutProcessors(textOrSourceCode, config, options));
}
  • _verifyWithProcessor 中就是具体的处理器调用逻辑
_verifyWithProcessor(textOrSourceCode, config, options, configForRecursive) {
    // 1. 先调用 preprocess
    const messageLists = preprocess(text, filenameToExpose).map((block, i) => {
        if (typeof block === "string") {
            // 2. preprocess处理完某个文件通过回调调用 _verifyWithoutProcessors
            return this._verifyWithoutProcessors(block, config, options);
        }
        const blockText = block.text;
        const blockName = path.join(filename, `${i}_${block.filename}`);
        // Does lint.
        return this._verifyWithoutProcessors(
            blockText,
            config,
            { ...options, filename: blockName, physicalFilename }
        );
    });
    // 3. 最后将就改的信息传给 postprocess
    return postprocess(messageLists, filenameToExpose);
}
  • _verifyWithoutProcessors 这个方法里真正开始了 parse 过程, parse 方法默认传入 espree,也可以在配置项中修改解析器。
_verifyWithoutProcessors(textOrSourceCode, providedConfig, providedOptions) {
    // ...获取项目中eslint自定义的配置信息
    if (!slots.lastSourceCode) {
        // 解析ast
        const parseResult = parse(
            text,
            languageOptions,
            options.filename
        );
        slots.lastSourceCode = parseResult.sourceCode;
    } else {
        // 如果代码没有范围管理器就去分析该范围再parse。
        if (!slots.lastSourceCode.scopeManager) {
            slots.lastSourceCode = new SourceCode({...});
        }
    }
   //  ...
    try {
        // 运行选中的配置项
        lintingProblems = runRules(...);
    } catch (err) {

    }
    // return 修改的注释或报告
    return applyDisableDirectives();
}
  • parse

    • parse 就做了一件事,调用解析器里的 parseForESLint 方法,这个方法在做 ESLint 的测试的时候也经常会被改写来方便测试,总之他就是用来解析 AST 语法。

    • 如果解析成功就返回一个 AST 语法对象。

function parse(text, languageOptions, filePath) {
    parseResult = (typeof parser.parseForESLint === "function")
            ? parser.parseForESLint(textToParse, parserOptions)
            : { ast: parser.parse(textToParse, parserOptions) };
}

step2: 运行规则 - runRules

对 AST 进行检查,返回结果和错误信息。

首先先分析 traverser

function runRules(){
  // ...
  Traverser.traverse(sourceCode.ast, {
      enter(node, parent) {
          node.parent = parent;
          nodeQueue.push({ isEntering: true, node });
      },
      leave(node) {
          nodeQueue.push({ isEntering: false, node });
      },
      visitorKeys: sourceCode.visitorKeys
  });
  // ...
}
  • 这部分就是通过实例化一个 Traverser 来递归遍历给定的AST树。

  • 在这个递归遍历的过程中都会调用传入的 enterleave,将当前节点保存起来。

_traverse(node, parent) {
    this._enter(node, parent);
    if (!this._skipped && !this._broken) {
        const keys = getVisitorKeys(this._visitorKeys, node);
        if (keys.length >= 1) {
            this._parents.push(node);
            for (let i = 0; i < keys.length && !this._broken; ++i) {
                const child = node[keys[i]];

                if (Array.isArray(child)) {
                    for (let j = 0; j < child.length && !this._broken; ++j) {
                        this._traverse(child[j], node);
                    }
                } else {
                    this._traverse(child, node);
                }
            }
            this._parents.pop();
        }
    }
    if (!this._broken) {
        this._leave(node, parent);
    }
    this._current = parent;
}

其次,在这里遍历每一条 rule 来查找相关的 rule.js 文件

  • 通过 ruleListeners 存储相关 rulecreated() 返回值,这里一般都是各种表达式命名的方法,ESLint 就是通过各种规则中的表达式函数来处理代码问题的。

  • 我们递归得到 ruleListeners 来把每一个表达式放入到 AST 的对应位置上。

Object.keys(configuredRules).forEach(ruleId => {
    const severity = ConfigOps.getRuleSeverity(configuredRules[ruleId]);
    if (severity === 0) {
        return;
    }
    // 找对对应规则的js文件路径
    const rule = ruleMapper(ruleId);
    const messageIds = rule.meta && rule.meta.messages;
    let reportTranslator = null;
    const ruleContext = Object.freeze();
    const ruleListeners = createRuleListeners(rule, ruleContext);
    Object.keys(ruleListeners).forEach(selector => {
        const ruleListener = timing.enabled
            ? timing.time(ruleId, ruleListeners[selector])
            : ruleListeners[selector];
        emitter.on(
            selector,
            addRuleErrorHandler(ruleListener)
        );
    });
});

这里我们举个例子,比如 ESLint 中有一条规则 require-yield, 此规则会为没有 yield 关键字的生成器函数生成警告。

  • 通过 Object.keys(configuredRules) 过滤出 require-yield 规则

  • 通过上文的 ruleMapper() 对规则进行了查询,我们查到了 require-rule.js 方法

  • 通过 createRuleListeners 取出 create() 中的各种节点声明

  • 最后将取出的方法存储在 emitter 中,等待调用

// require-yield 中create存储的方法
{
    FunctionDeclaration: beginChecking,
    "FunctionDeclaration:exit": endChecking,
    FunctionExpression: beginChecking,
    "FunctionExpression:exit": endChecking,
    YieldExpression() {
        if (stack.length > 0) {
            stack[stack.length - 1] += 1;
        }
    }
};

最后,在最后我们遍历 nodeQueue 中存入的 AST 结构,eventGenerator 存入的是各种各样在 create 方法中的表达式函数,在这个遍历的过程中进行的才是源代码的错误定位。

nodeQueue.forEach(traversalInfo => {
    currentNode = traversalInfo.node;
    try {
        if (traversalInfo.isEntering) {
            eventGenerator.enterNode(currentNode);
        } else {
            eventGenerator.leaveNode(currentNode);
        }
    } catch (err) {
        err.currentNode = currentNode;
        throw err;
    }
});

step3: 修复 - applyFixes

applyFixes 方法就是尝试调用每个 problem 的 fix() 去尝试修复他,到这里就修改完毕啦,剩下的就是把正确的代码重新写入文件。

messages.forEach(problem => {
    if (Object.prototype.hasOwnProperty.call(problem, "fix")) {
        fixes.push(problem);
    } else {
        remainingMessages.push(problem);
    }
});

尝试自定义 ESLint 插件:

在这里我们实现一个简易版的 eslint-plugin-sort

我们想实现的仅仅是简单的修改输入 import 顺序。

比如如下的方案:绝对路径调整到相对路径之前,注意:这个案例只是实现多个 import 顺序的修改,不会去实现单个 import 内部的排序。

// before
import x from "../ccc";import d from "@d";
import { e, b, a as c } from "specifiers";
// after
import d from "@d";

import { e, b, a as c } from "specifiers";

import x from "../ccc";

逻辑上我们要从 Program 入手,获取所有 body 中的 ImportDeclaration 声明语句,然后将获取到的代码片段做排序即可。

现在来一步步实现代码逻辑 ⬇️

确定框架

首先通过官方提供的工具 generator-eslint 生成模板,官方推荐用 mocha 测试,那照抄即可,选择项一路默认没有问题,如果需要对非 JS 文件做修改则需要配置 process。

npm install -g yo generator-eslint
mkdir eslint-plugin-test
cd eslint-plugin-test
yo eslint:plugin
  • ESLint 代码逻辑写在 lib/rules/test.js

  • ESLint 测试逻辑写在 tests/lib/rules/test.js

现在可以根据官方模板先搭建基础框架

这里的 create 就是上文的修复代码逻辑的入口,Program 只是一个例子,修复逻辑从我们想要他进入的入口开始执行逻辑,最后我们输出 report 和相关的 fix。

根据 astexplorer.net/ 看到的结构,捕获节点的时机可以从 import 的上级结构 Program 开始

  • create 的返回值对象是 AST 的各种结构声明,ESLint 工作时会取出每个规则中 create 的返回值:即各种结构的调用函数。
  • 将这些函数存入事件队列中,当 ESLint 开始遍历 AST 结构时会调用这些函数。
// lib/rules/test.js
// step1
module.exports = {
  meta: {
    type: 'layout', // `problem`, `suggestion`, or `layout`
    fixable: 'code', // Or `code` or `whitespace`
    schema: [], 
  },
  create(context) {
    // 1. 确定导入顺序的正则逻辑
    const defaultGroups = ["^@\w", "^[^@|\.]", "^\."];
    const outerGroups = defaultGroups.map((item) => RegExp(item, "u"));
    return {
      // 2. Program 是程序主入口
      Program: (programNode) => {
         // step2
      },
    };
  },
};

提取文件中 import 部分,将 import 存入 chunk

import 的结构类型是 ImportDeclaration,所以判断当前节点的类型是否是 ImportDeclaration 就可以拆出 import 结构。

// step2
Program: (programNode) => {
  function findNode(node) {
    return node.type === "ImportDeclaration" ? "PartOfChunk" : "NotPartOfChunk"
  }
  for (const chunk of util.extractChunks(programNode, findNode)) {
    // step3
    maybeReportChunkSorting(chunk, context, outerGroups);
  }
},

extractChunks 的具体逻辑如下,只要是能取出 ImportDeclaration 即可。

// util.js
function extractChunks(programNode, isPartOfChunk) {
  const chunks = [];
  let chunk = [];
  let lastNode = undefined;
  for (const node of programNode.body) {
    const result = isPartOfChunk(node, lastNode);
    switch (result) {
      case "PartOfChunk":
        chunk.push(node);
        break;

      case "PartOfNewChunk":
        if (chunk.length > 0) {
          chunks.push(chunk);
        }
        chunk = [node];
        break;

      case "NotPartOfChunk":
        if (chunk.length > 0) {
          chunks.push(chunk);
          chunk = [];
        }
        break;
      default:
        throw new Error(`Unknown chunk result: ${result}`);
    }
    lastNode = node;
  }
  if (chunk.length > 0) {
    chunks.push(chunk);
  }

  return chunks;
}

接下来就是确定 Import 排序逻辑和写出 ESLint 修复函数

function maybeReportChunkSorting(chunk, context, outerGroups) {
  const sourceCode = context.getSourceCode();
  // 1. 取 import 结构
  const items = util.getImportExportItems(
    chunk,
    sourceCode
  );
  // 2. 利用正则对获得的 import 结构做排序
  const sortedItems = makeSortedItems(items, outerGroups);
  // 3. 输出字符串形式的代码结构
  const sorted = util.printSortedItems(sortedItems, items, sourceCode);
  const { start } = items[0];
  const { end } = items[items.length - 1];
  // 4. 错误报告 && fix修复
  util.maybeReportSorting(context, sorted, start, end);
}

step1: 获取 import 结构

接下来要通过 getImportExportItems() 方法获取 import 的具体结构,在这里整理原始代码,获得的结果如下⬇️:

[
  {
    node:...,
    code: 'import x from "../ccc";',
    source: { ... },
    index: 0,
  },
  {
    node: ...,
    code: 'import { e, b, a as c } from "aa";',
    source: { ... },
    index: 1,
  },
  {
    node: ...,
    code: 'import d from "@a";',
    source: { ... },
    index: 2,
  }
]

getImportExportItems() 函数定义如下

function getImportExportItems(chunk, sourceCode) {
  return chunk.map((node, nodeIndex) => {
    const code = printWithSortedSpecifiers(node, sourceCode);
    const [start, end] = node.range;
    const source = getSource(node);
    return {
      node,
      code,
      start: start,
      end: end,
      source,
      index: nodeIndex,
      needsNewline: false,
    };
  });
}
function printWithSortedSpecifiers(node, sourceCode) {
  const tokens = sourceCode.getTokens(node);
  const lastTokenIndex = tokens.length - 1;
  const allTokens = flatMap(tokens, (token, tokenIndex) => {
    const newToken = { ...token, code: sourceCode.getText(token) };
    if (tokenIndex === lastTokenIndex) {
      return [newToken];
    }
    const last = token;
    const nextToken = tokens[tokenIndex + 1];
    const result = [
      newToken,
      ...parseWhitespace(
        sourceCode.text.slice(last.range[1], nextToken.range[0])
      ),
    ];
    return result;
  });
  return allTokens.map((token) => token.code).join("");
}

step2: 利用正则对获得的 import 结构做排序

这里规定了三种 import 类型,'@xx'、'xx'、'./xx',下面会对三种不同类型的 import 做区分,通过正则将他们存入不同的数组中。

function makeSortedItems(items, outerGroups) {
  const itemGroups = outerGroups.map((regex) => {
    return { regex, items: [] };
  });
  for (const item of items) {
    const { originalSource } = item.source;
    const source =
      item.source.kind !== "value" ? `${originalSource}\0` : originalSource;
    for (let index = 0; index < itemGroups.length; index++) {
      if (itemGroups[index].regex.exec(source)) {
        itemGroups[index].items.push(item);
        break;
      }
    }
  }
  return itemGroups.filter((t) => t.items.length > 0);
}
// reuslt ⬇️
// [
//   { regex: /^@\w/u, items: [ [Object] ] },
//   { regex: /^[^@|.]/u, items: [ [Object] ] },
//   { regex: /^./u, items: [ [Object] ] }
// ]

step3: 输出字符串形式的代码结构

上面已经完成了 import 的分类,接下来要做的是确定输出格式并且提供给 fix 函数中,如果写的 ESLint不需要修复,那这里只要输出报告就可以了。

function printSortedItems(sortedItems, originalItems, sourceCode) {
  const newline = guessNewline(sourceCode);
  const sorted = sortedItems.map(item => {
    const codeline = item.items.map(codes => codes.code.concat(newline))
   return codeline
  }).join(newline)
  return sorted
}
// result ⬇️
// import d from "@a";\n\nimport { e, b, a as c } from "aa";\n\nimport x from "../ccc";\n

step4: report && fix

sorted 是我们修复好的字符串,在 fix 方法中直接获取之前截取出来的 startend 位置,通过ESLint 的 replaceTextRange 将新旧代码替换即可。

function maybeReportSorting(context, sorted, start, end) {
  const sourceCode = context.getSourceCode();
  const original = sourceCode.getText().slice(start, end);
  if (original !== sorted) {
    context.report({
      messageId: "error",
      loc: {
        start: sourceCode.getLocFromIndex(start),
        end: sourceCode.getLocFromIndex(end),
      },
      fix: (fixer) => {
        return fixer.replaceTextRange([start, end], sorted);
      },
    });
  }
}

测试代码逻辑

lib/rule/test.js 中的 invalid 写入错误样例就可以完成测试了。

ruleTester.run("test", rule, {
  valid: [
    // give me some code that won't trigger a warning
  ],

  invalid: [
    { only: true,
      code: `import x from "../ccc";import { e, b, a as c } from "aa";import d from "@a";`,
      output: `import d from "@a";\n\nimport { e, b, a as c } from "aa";\n\nimport x from "../ccc";\n`,
      errors: [{ message: "Run autofix to sort these imports!" }],
    },
  ],
});

结果如下 ⬇️

完结下班🎉🎉🎉