【总结】1526- ESLint 核心概念梳理与常用配置指引

442次阅读  |  发布于1年以前

ESLint 基于规则对代码进行约束,或抛出问题和警告,或提供一键修复功能。其繁荣的社区生态提供了众多优秀的预设配置方案,我们可以引用这些优秀方案,并加以修改,快速定制出一份团队或个人的专属配置。

而规则的实现基于对 AST 的分析和处理,这意味着我们可以通过开发自定义规则实现任何我们想要的校验效果,这也正是它卓越灵活性的体现。功能虽然强大,但对于不熟悉 ESLint 的同学来说,因为一些报错搞得一头雾水也是常有的事。

如同台上熠熠生辉的演员,少不了剧组幕后付出的努力。规则的实现亦是离不开各个模块之间的紧密配合。为了发挥 ESLint 的最大价值,我们有必要了解它的核心概念、工作流程、以及配置方式。希望通过本文的介绍,能够帮助大家建立初步印象,并在日后的开发过程中生根发芽。

配置方式

首先需要强调 官网[1] 的重要性,我们大可不必上网搜索一堆二手资料,大部分疑问都可以在官网中直接找到答案,这里挑选了一些不常用到但却很有用的配置进行说明。使用官网给出的初始化方式,运行npm init @eslint/config并进行一系列命令行交互后,我们可以得到一份预设配置。

其次要推荐一个配置文件编写工具 —— eslint-define-config[2]。众所周知 ESLint 没有提供配置文件的相关类型声明,对于不熟悉的同学来说,凭空编写配置具有一定的难度,而在这个工具的辅助下,你可以自信满满的编写自己的配置。它详细到能将常用规则的具体配置提示出来。

配置文件

root

相关说明在这里 —— ESLint - Cascading and Hierarchy[3]

简要概括就是,ESLint 会从配置文件所在目录向上查找配置直到项目根目录,找到了配置之后则会与当前文件的配置进行合并,解析出最终的配置。其中项目根目录指的是距离最近的一份package.json文件所在的目录,而配置可能是独立的文件,也可能包含在package.json中的 eslintConfig 字段中。

这一行为在某些情况下非常有用,例如在 monorepo 中针对不同的子项目目录设置不同的规则,我们可以在根目录定义基础规则,并在各子目录中定义衍生配置。但这有时候也会造成一些疑惑,例如当我们对项目不熟悉时,可能不知道为什么校验结果中出现了一些没有配置的规则。

而加上 root: true 配置后,ESLint 解析到该配置文件时将会停止向上查找。大家可以根据自己的需要决定是否开启,通常建议开启。但如果你的项目中只有一份配置文件并且放在根目录,那就无关紧要了。

extends

相关说明在这里 —— ESLint - Extending Configuration Files[4]

简要概括就是,可以继承另一个配置文件的所有特性,包括规则、插件、环境变量等等,并在继承的基础上与当前文件的配置进行合并。

该字段的值可以是:

  1. 字符串:文件的路径、共享配置名称、插件提供的配置、内置配置如eslint:recommended
  2. 字符串数组:按照书写顺序,后面的配置继承前面的配置,最终被当前配置文件继承

overrides

简单来说,overrides 配置项通过 files 字段指定匹配范围,匹配模式为通配符,其他字段与基本配置相同。它可以在继承当前配置文件其他配置的情况下,对匹配的文件应用一份单独的配置。

例如在引入 jest 测试框架后,我们的测试文件包含许多 jest 独有的环境变量,我们可以通过 overrides 为测试文件配置环境变量 jest: true

{
  overrides: [
    {
      files: ["tests/**/*.spec.js"],
      env: {
        jest: true
      },
      rules: {},
      ...
    }
  ]
}

环境变量

globals

定义全局变量,详见 —— ESLint - Specifying Globals[5]

有时为了方便,我们会通过构建插件如 webpack.DefinePlugin 定义一些开发、构建阶段可见的全局变量,例如广泛使用的__DEV__变量。但对于ESLint来说它是不认识这些变量的,因此它会将这些变量当做常规变量进行解析,从而导致如下报错:

image

此时我们需要在 ESLint 中配置 globals 字段,定义一个全局变量类型,告诉 ESLint 指定变量的解析方式—— readonlywritableoff

module.exports = {
  globals: {
    __DEV__: 'readonly',
    var1: 'writable',
    require: 'off', // 禁用 require 语法
  },
}

env

详细说明 —— ESLint - Specifying Environments[6]

简要概括,env 就是预设的 globals 变量集合,我们最常用的主要是 node 环境和 browser 环境,他们提供了众多全局变量,例如 require、__dirname、window、document 等等。更多预设环境变量可以查看官网说明。

module.exports = {
  env: {
    node: true,
    browser: true,
  },
}

插件和规则

通常来说,我们使用的共享配置都可以在 Github 上对应的项目 README 中找到使用方式。而诸如 Vue、TypeScript 此等级别的工具更是提供了相应的官方文档站点,这里就不展开细讲了。

除此之外,遵守约定的规则会提供完备的**规则元信息(meta字段)**,其中至少包含了规则的使用说明文档,我们可以在报错的时候点击相关链接跳转到站点查看使用详情和注意事项。

通常官方提供的共享配置都是遵守约定并且完备的,例如 Vue、TypeScript 相关规则报错如下:

image

image

规则的等级

规则禁用原则

当我们迫不得已需要禁用某些规则时,需要遵循 影响范围最小 的原则:

  1. 行内注释关闭某一处校验,格式为 eslint-disabled(-next)-line: xxx
import('./test'/* eslint-disabled-line path-with-extension */);
import('./test'); // eslint-disabled-line path-with-extension

// eslint-disabled-next-line path-with-extension
import('./test');

2 . 使用文件头部注释关闭某一个文件中指定规则的校验。格式为/* eslint-disable xxx */

我们不推荐大家使用纯粹的 /* eslint-disable */注释,因为该注释会导致文件被彻底排除在规则校验之外,但凡使用头部注释,请务必指定具体规则,例如禁用文件中的 xxx 规则,可以使用类似这样的写法:/* eslint-disable xxx */

3 . 我们也可以利用 overrides 字段进行配置,如:

module.exports = {
  overrides: [
    {
      files: [
        'path/to/your/file',
        'glob/match/your/file',
        ...
      ],
      rules: {
        'vue/no-parsing-error': 'off',
      },
    },
  ],
};

核心概念

Processors 处理器

每一个 processor 包含两个钩子 preprocesspostprocess。preprocess 接收文件内容和文件路径,它负责将一个文件分隔成若干个独立的代码块;postprocess 用于处理代码块在 Lint 过程中产生的消息,并还原分割后的代码块和源文件之间的映射关系,以便ESLint能够在错误消息中正确输出错误发生的位置。

除此之外还有一些其他的配置项,由于重点介绍工作流,关于 processor 的详细信息和开发指南不做过多展开,详细信息可以参考官方文档,附上链接 —— Processors-in-plugins[10]。

processor 在插件中的注册方式如下,以 eslint-pugin-vue[11] 为例:

// eslint-plugin-vue/lib/processor.js
module.exports = {
  ...
  processors: {
    '.vue': require('./processor')
  },
  ...
}

processor 的作用之一是对文件进行预处理,让其变成能够被ESLint识别的若干独立代码块。

module.exports = {
  processors: {
    'processor-name': {
      preprocess: function(text, filename) {
        // 你可以在这里剔除非JS内容,并将文件内容分隔成若干个需要进行检查的代码块
        return [
          { text: code1, filename: '0.js' },
          { text: code2, filename: '1.js' },
        ];
      },
      postprocess: ...
      ... // 其他配置
    },
  },
};

我们可以在processor中将文件分割,也可以选择性丢弃一些不需要处理的内容。在接下来的流程中,分割后的代码块根据文件后缀的不同,将被传输给对应的Parser进行解析。

假设我们在插件中注册了一个 markdown 文件的 processor,处理下面的示例文件:

# test.md
There are some plain text don't need to check.

```js
console.log('There are some scripts can be split into code blocks');

我们可以将其中的JS代码块解析出来并返回,丢弃其他文本内容,那么最终只有JS代码会被检测。如果processor 返回的内容为空,当前文件将被忽略。

```JavaScript
function preprocessor(text, filename) {
  return [
    { code: /* JS */, filename: '0.js' },
    ...
  ]
}

同样以 eslint-pugin-vue 中的 processor[12] 为例:

// eslint-plugin-vue/lib/processor.js
module.exports = {
  ...
  preprocess(code) {
    return [code]
  },
  ...
}

看上去 eslint-plugin-vue 插件中的 processor 仅仅是原样返回 vue 文件的代码,按照上述逻辑,不是因该先将 vue 源码分割成 template.htmlscript.jsstyle.css,然后再分别处理吗?

理论上是可以这样做的。但是 Vue SFC 的三个子模块之间通常有着变量引用的联系,例如模板中引种的变量是否在脚本中声明?分割成三个子模块单独处理将会丢失这些联系。为了更好的处理这种这种情况,Vue 团队专门定制了 vue-eslint-parser 作为最佳解决方案。

Parser 语法解析

同样地,Parser 的详细介绍和开发指南,参见官方文档 —— Working with Custom Parsers[13]。

Parser 负责将输入代码转化为对应的 抽象语法树(AST) ,并提供 可选的遍历器AST Visitors)以供 ESLint 调度。

为什么 遍历器 是可选的呢?

目前社区内大部分 JavaScript AST 的构建都遵循着 estree[14] 规范,但规范中的 AST 节点类型有限,开发者常常需要定义专属的 AST 节点,例如 Vue、TypeScript 中就有若干 特有节点。而 ESLint 默认使用 estree 规范下的 AST 进行节点的遍历分析,因此对于规范之外的节点的遍历需要 Parser 提供相关能力支持。

ESLint 的 规则(Rules)体系建立在对 AST 的分析之上,从这点上来说,Parser 的作用举足轻重。

常用的 Parser

以Vue项目的开发为例,常用的 parser 主要有三个:

  1. @babel/eslint-parser[15]:ESLint 原生支持 JS 的处理,它默认使用 espree[16] 进行解析。但就像 @babel/eslint-parser在 README 中提到的那样,espree 只支持已经落地的ES标准语法,而现阶段对于实验性语法的转译还是得依靠 Babel。
  2. @typescript-eslint/parser[17]:处理 TS 文件所需的 Parser。
  3. vue-eslint-parser[18]:处理 Vue 文件所需的 Parser,主要是处理一些 Vue 独有的AST节点,特别是处理 template 模块。

日常开发过程中,我们通过官网提供的方式生成vue项目的配置文件时,常常发现生成的配置中存在两个 parser。例如vue3项目,vue-eslint-parser为主,@typescript-eslint/parser为辅;vue2的js项目中则是@babel/eslint-parser为辅;而纯粹的 JS 项目或 TS 项目中则往往只有一个@babel/eslint-parser 或 @typescript-eslint/parser。示例文件如下:

module.export = {
  parser: 'vue-eslint-parser', // Primary parser
  parserOptions: {
    parser: '@babel/eslint-parser', // Backup parser
    sourceType: 'module',
  },
}

这两个 parser 有什么区别吗?” —— 许多不熟悉 ESLint 的同学在配置 vue 项目的 parser 时常常会产生这样的疑问。实际上标准的 ESLint 配置中关于 parser 的配置只有 parserparserOptions 两个选项。

parserOptions.parser 仅仅是 vue-eslint-parser 众多配置项中的一项而已,它的作用是告诉 vue-eslint-parser 使用何种方式处理 SFC 文件中 script 标签下的语法。若未指定,则会使用 ESLint 默认的 espree 进行解析。关于 vue-eslint-parser 的更多详细配置请移步官方文档[19]。

Rules 规则

规则用来定义具体的校验逻辑和校验范围,详见 —— Working with Rules[20]。

经过 Processor 和 Parser 的处理,输入代码已经变成了若干 AST 节点。Rules 要做的就是对这些 AST 节点进行分析,找出其中不符合预期的代码并将错误抛出。

下面是一条关于限制 import 引用路径须以后缀名结尾的简单规则示例:


module.exports = {
  meta: {
    type: 'problem',
    docs: {
      recommended: true,
      description: 'Help to find import source literals that are not ending with file extension.',
    },
    ... // 定义规则元信息
  },
  create(ctx) {
    /**
     * 定义校验逻辑
     * 限制引用路径以后缀名结尾
     */
    function handler(node) {
      if(/\.\w+$/.test(node.value)){
        ctx.report({
          node,
          message: 'Path should end with extension',
        });
      }
    }

    return {
      'ImportDeclaration': handler,
      'ImportExpression': handler,
    };
  },
};

一条规则包含 metacreate 两个字段,meta 字段为对象类型,我们可以在其中定义规则元信息,如描述、消息模板等;create 字段为函数类型,它接收上下文作为参数,并返回 AST 遍历器,主要用于定义规则主体。

其中遍历器的键为选择器,其语法与CSS选择器很相似,值则是一个接收当前选定 AST 节点的函数。以 eslint-plugin-vue 中部分规则的选择器为例:

"VDirectiveKey > VExpressionContainer"
"VDocumentFragment:exit"
"VElement[parent.type!='VElement']:exit"

那我们要如何获取这些选择器的名称呢

不得不提到开源项目 AST Explorer[21]。将代码粘贴在左侧,并在上方操作栏选择对应的 Parser,即可在右侧看到解析后的 AST 语法树,而我们需要的选择器名称是 AST 节点的 type 属性:

image

这样看来,规则的编写并非如我们想象中那样复杂。由于巨人们已经帮我们写好了各种 Parser,做了充分的铺垫,我们只需要将目光聚焦在如何从 **AST** 中找到我们需要分析的节点并进行处理。例如决定是否抛出提示、是否支持自动修复、如何修复等等。当然了,这些操作都可以在官方文档的相应章节找到教程,这里就不细讲了。

Shareable Config 配置

“共享配置”无处不在,常见于配置文件的形式,如.eslintrc.js.eslint.json

作为 ESLint 的使用者,实际上每一份配置文件都是可以 “共享” 的。我们只需在配置文件的 **extends** 字段中列举出想要使用的配置文件的路径即可,ESLint 会自动读取对应的配置文件并合并配置。

而配置的共享方式也是多样的,常见于通过 npm 包的形式共享。我们可以通过 npm 发布一份自己的配置,使用者安装这些包,并在配置文件的 extends 字段中引用即可达到“共享”的效果。

由于涉及到发包,出于生态建设的目的,ESLint 对配置包的包名进行了约束。

命名规范

ESLint 的共享配置包通常遵循着一定的命名规范,基于该规范,ESLint 会为对应的配置进行补全。规范主要有两种形式 eslint-config-xxx@scope/eslint-config-xxx,这两种名称的包的配置方式示例如下:

module.exports = {
  extends: [
    'standard', // eslint-config-standard
    'standard-with-typescript', // eslint-config-standard-with-typescript
    '@vue/typescript/recommended',//@vue/eslint-config-typescript/recommended
  ],
};

ESLint 对配置路径的补全是为了简化使用方式,本质上仍是为了找到目标配置文件。如果配置包名不遵循这样的规范,也并非无法使用,只是相对麻烦了些。我们需要指明配置文件的路径,如:

module.exports = {
  extends: [
    // 需要提供具体配置文件的路径
    './node_modules/my-eslint-coding/lib/configs/vue2.js',
  ],
};

Plugins 插件

插件的详细开发指南参考官网章节 —— Working with Plugins[22]。

插件的能力是最最全面的,上文的各个小节中提到的配置,都可以在插件中直接或间接地配置。我们可以简单地认为插件是这些能力的一个集合,插件包中导出文件的结构示例:

// eslint-plugin-test/index.js
module.exports = {
  // 配置预处理器
  processors: require('./mdx/processor'),
  // 插件提供的可以配置的规则
  rules: {
    'path-with-extension': require('./rules/path-with-extension'),
  },
  // 可使用的共享配置
  configs: {
    ts: require('./configs/ts'),
    vue3: require('./configs/vue3'),
  },
  // 插件提供的一些环境变量配置
  environments: {
    dev: {
      globals: {
        __DEV__: 'readonly',
      },
    },
  },
};

同样地,插件包的命名规范与共享配置包的命名规范相似,只不过将 config 替换成了 plugin,例如 eslint-plugin-xxx@scope/eslint-plugin-xxx

使用方式

假设我们的插件名称叫做 eslint-plugin-test,那么插件提供的预设环境变量、规则、配置可以通过以下方式在对应位置被引用:

module.exports = {
  env: {
    // 环境变量
    'test/dev': true // { '[plugin]/envName': true }
  },
  rules: {
    // 规则集
    'test/path-with-extension': 'error', //  { '[plugin]/ruleName': 规则配置 }
  },
  extends: [
    // 共享配置继承
    'test/ts', // '[plugin]/configName'
    'test/vue3',
  ],
};

总结

本文着重介绍了 ESLint 的核心概念,并衍生介绍了常用的配置方式。

如果你是 ESLint 的使用者,那么着重关注 Parser、插件、规则、环境变量的引用和配置方式;

如果是 ESLint 的开发者,则需要对全局视角有一定了解,并聚焦在你所关注的模块,例如规则开发主要关注 AST 的选择和处理,而 Parser 的开发则主要关注 AST 的生成和遍历。

除此之外,上面提到核心概念按照工作流程顺序串联起来大致如下:

  1. 文件首先经过 processors 处理成为若干片段;
  2. 若干片段根据类型不同被传输给对应的 parser 进行解析,生成若干 AST 节点;
  3. 应用规则进行分析,输出错误信息;
  4. 错误信息经过 processors 还原,映射到源文件中。

了解上述流程和概念,相信日后无论是参与 ESLint 定制功能开发,还是运行时问题排查,都能做到心中有数。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8