聊聊 ESLint 及相关调试技巧

文摘   2024-08-15 09:00   北京  

来自团队同学「文明」的技术分享。

我是范文杰,一个专注于工程化领域的前端工程师,近期有不少 HC,感兴趣的同学可联系我内推!欢迎关注:

关于eslint

ESLint 是一个高度可配置的 Javascript 代码静态分析工具,其中静态分析是指将源码转为 AST 后,基于AST进行遍历、应用规则、上报问题;而可配置性则使得 ESLint 能够满足各种项目需求,极大地增强了其功能。例如:

  • 校验规则(Rules) :ESLint 允许根据项目需求灵活配置规则,这些规则集可以作为基础配置,帮助开发者快速上手并维护一致的代码风格。社区中常见的 ESLint 规则集包括:
    • eslint-config-airbnb:遵循 Airbnb 的 JavaScript 风格指南。
    • eslint-config-google:遵循 Google 的 JavaScript 风格指南。
  • 扩展插件(Plugins) :ESLint 支持通过插件扩展新的规则,插件可以增加 ESLint 的功能,使其能够处理更多的代码规范和风格要求,例如:
    • @typescript-eslint/eslint-plugin:用于扩展 TypeScript 的规则。
  • 解析器(Parser) :虽然 ESLint 是 JavaScript 的静态分析工具,但其功能并不限于此。通过配置解析器,ESLint 可以解析和处理其他语言,例如:
    • @typescript-eslint/parser:将 TypeScript 文件解析为 ESTree 兼容的 AST 格式,使 ESLint 能够识别和处理 TypeScript 文件。
    • yaml-eslint-parser:使 ESLint 能够识别和处理 YAML 文件,并应用相应的规则。
  • 处理器(Processors) :ESLint 还可以通过配置处理器来处理其他格式文档中的代码。例如:
    • eslint-plugin-markdown:可以 lint Markdown 文件中的 JavaScript 和 TypeScript 代码片段。
    • eslint-plugin-html:可以处理 HTML 文件中的 <script> 标签内的代码片段。

不过,初学者总容易混淆上面提到的若干概念,这里先集中介绍一下:


parser和processor的区别

不知道大家有没有跟我一样的困惑,为什么yaml文件可以通过yaml-eslint-parser处理,但是makdown或者html文件是通过plugin的processors处理?yaml文件的lint配置:

{
  "files": ["*.yaml""*.yml"],
  "parser""yaml-eslint-parser",
  "plugins": ["yaml"],
  "extends": ["plugin:yaml/recommended"]
}
  markdown文件的lint配置:
{
    "files": ["*.md"],
    "processor""markdown/markdown"
}
  • 解析器(parser) :负责将代码转换为 AST,直接影响 ESLint 如何解析文件。

    换句话说,代码能够转出eslint能够识别的AST格式(参考eslint的AST规范,本质上就是ESTree的兼容格式)

  • 处理器(processor) :负责预处理和后处理文件内容,主要用于处理非 JavaScript 文件中特定的文件片段。比如说,markdown这种语言并不是一门编程语言,也就没办法转换Estree格式的AST,但是我们却想lint其中的js代码,就可以通过plugin的preocessor提取其中的代码片段,然后通过处理。


config和plugin的区别:

config简单理解就是一份完整的eslint配置文件,我们可以在配置中通过extends的来继承和使用这些配置:

   {
     "parser""@typescript-eslint/parser",
     "plugins": ["@typescript-eslint"],
     "extends": [
       "eslint:recommended",
       "plugin:@typescript-eslint/recommended"
     ],
     "rules": {
       "@typescript-eslint/no-unused-vars""error",
       "no-console""warn"
     },
     "parserOptions": {
       "ecmaVersion": 2021,
       "sourceType""module"
     }
   }

可能你注意到,plugin除了可以配置在plugins中,也可以配置在extends,其实plugin除了声明规则、processer(预处理器),也可以声明配置,例如声明一份推荐的配置:

// plugin config
module.exports = {
    configs: {
        recommended: {
            plugins: ["myPlugin"],
            env: ["browser"],
            rules: {
                semi: "error",
                "myPlugin/my-rule""error",
                "eslint-plugin-myPlugin/another-rule""error"
            }
        }
      rules: {
          'some-rule': {/* rule definition */},
      },
      processers: { //... }
    },

extends里面配置的其实都是一份eslint配置,意味着这份配置文件也可以通过extends字段配置另一个eslint配置,然后另外一个配置文件也可以extends配置... ...

事情好像变得复杂一些了,下一节将会专门讲解eslint配置的复杂度。

eslint配置的复杂度

参考:

  1. https://eslint.org/blog/2022/08/new-config-system-part-1/
  2. https://eslint.org/blog/2022/08/new-config-system-part-2/

你是否有以下困惑:

  • eslint的配置文件怎么那么多?.eslintrc.js .eslintrc.cjs .eslintrc.yaml .eslintrc.yml .eslintrc.json package.json
  • .eslint.js 的plugin配置和config配置为什么要写字符串?为什么要省略eslint-config?而不是完整的npm包名?
  • 怎么莫名其妙有一些新的规则?
  • 我的规则配置怎么没有生效?plugin好像没有生效?
  • ......

其实eslint最早期只支持 .eslintrc 配置(本质上是json格式),作为特性后面支持让用户使用更多格式的配置文件,也就看到上述的这么多配置文件。

yml和json是可以相互转换的,倒也还好,但是js的对象并不能完全转换为json。例如plugin中的配置是可以配置使用正则表达式,但是json文件中其实不方便配置正则(虽然可以通过一些方法去解析),越来越多plugin支持配置js语法,这也让后续js文件的配置格式逐渐成为主流。

但是为了兼容json格式的配置,config和plugin保留了使用字符串的特性,其实就是为了配置的简洁性,其实是约定的可以省略一些配置前缀。

这意味着如何去加载和处理这些配置的完全是eslint自己去处理的(eslint如果解析不到声明的这些包,配置将会无效),但是我们共享和发布的config或者plugin的包对于他们依赖的config或者plugin或者parser,官方推荐而只需声明 peerDependency(因为即使声明 dependency,eslint的还是按照自己的安装路径进行解析)

这个特性在npm v3之前都是没有问题的,因为npm3会自动安装 peerDependency,但是之后npm取消了这个特性......

很常见的,我们要安装使用一个config,我们还要安装好多其他包,以确保eslint能够正确解析。比如我们要使用:eslint-config-airbnb-typescript,安装后进行eslint配置:

extends: [
 'airbnb-typescript'
]

但是这个config其实还依赖 @typescript-eslint/eslint-plugin@typescript-eslint/parser,所以我们还需要手动安装这两个依赖,才能正常工作。这也是一些规则或者插件没有生效的原因之一。

除了使用 extends 复用eslint的配置,eslint还有一个早期就支持的特性叫配置级联(configuration cascade)。

eslint会根据当前lint文件的位置一直往他的上层文件夹找eslintrc配置文件并且合并这个配置,直到找到配置了"root":true的配置文件或者到用户配置目录 ~/.eslintrc

此外eslint还支持配置overrides 配置,让允许我们用glob表达式匹配文件,然后使用不同的规则。例如overrides可以针对ts文件和js文件配置不同的规则,当然overrides里面也是可以使用其他config的。

  "overrides": [
    {
      "files": ["**/*.ts""**/*.tsx"],
      "parser""@typescript-eslint/parser",
      "plugins": ["@typescript-eslint"],
      "extends": [
        "plugin:@typescript-eslint/recommended"
      ],
      "rules": {
        "@typescript-eslint/no-unused-vars": ["error"],
        "@typescript-eslint/explicit-module-boundary-types""off"
      }
    },

现在假设我们要用eslint检测一个文件,我们该如何获取检测这个文件需要的完整配置呢?

  1. 首先eslint会通过配置级联特性,逐个文件夹的去查找配置文件...
  2. 当然如果有多个格式的配置文件,他们有自己的优先级...
  3. 找完了所有的配置文件... 需要找到他们又 extends 使用了哪些配置文件... overrides 又配置了哪些规则...
  4. 当然这些配置文件也可能 extends其他配置... 当然 overrides 也可以extends 配置...
  5. ....
  6. 最后配置文件解析后,还要根据文件的指令以及cli的参数,合成最后的应用规则。

对文件应用的过程要合并各种配置,判断各种优先级,总而言之十分复杂和繁琐,连eslint作者本人也摇头。

上面所有的问题都是eslint8.x(我们项目中使用的版本)存在的,但是eslint9.x已经完全重新设计的eslintrc配置系统(8.x也可以通过环境ESLINT_USE_FLAT_CONFIG开启该特性),使用的flat config,解决了上述的很多问题。感兴趣可以参考本节开头引用的那两篇文章。

Can do and can't do

eslint的原理其实就决定了这个工具能做什么不能做什么?静态语法分析!所有基于静态语法分析能做的事情,eslint几乎都可胜任。

能做:

  1. 语法检测。如缺少分号、未定义的变量等。
  2. 代码风格检测。如缩进、空格、引号类型等。
  3. 最佳实践。React、React hooks等框架的最佳实践。
  4. 代码质量。如未使用的变量、未处理的 Promise 等。
  5. 自动修复。自动修复一些简单的代码问题。
  6. ...

不能做的:

  1. 运行时错误检测。代码到底跑是怎么样,还得靠自己保证。
  2. 复杂的逻辑检测。比如一些无法泛化和抽象的场景,因为JS语言很灵活,很多问题不太可能静态就检测出来。

不善于做的:

  1. 系统IO:虽然通过插件已经可以检测命名规范等功能,但是eslint的规则还是不太建议去调用系统IO去做一些事情,比如动态的去生成或者读取一些其他信息。
  2. 跨文件的代码分析:当然,一些 plugin也已经实现垮文件的分析比如eslint-plugin-import,它能检测依赖循环等功能。跨文件分析的关键在于通过语法解析到另外一个模块,这个plugin通过配置resolver,内置支持node的模块解析逻辑,也支持配置其他的解析逻辑如webpack、typescript的解析。所以如果有跨文件解析的需求可考虑直接使用eslint-modules-uitls

一些调试技巧

1. 如何检测eslint规则的性能?

我们可以在eslint命令前加上环境变量TIMING,并且设置为all或者*,就可以得到规则的耗时统计。

设置为*或者1,获取前10的耗时规则

设置以为all,获取所有耗时的规则

2. 如何知道某个文件应用了哪些规则?

上面关于eslint配置的复杂度介绍到,如果想知道一个文件到底生效和应用了哪些规则,单从配置文件上来看十分难以推断。有两种方法可以帮助我们直接拿到eslint最终应用的规则:

  • 方法一 :通过 --print-config 命令行参数: npx eslint --print-config src/index.ts
  • 方法二:通过 DEBUG 环境变量eslintrc:*,这个方法不仅能看到最终的config,还能看到整个查找和解析的过程: DEBUG=eslintrc:* npx eslint src/index.ts
  • 方法三:通过vscode eslint插件的outpout日志。
    • 首先打开vscode配置,把 "eslint.debug"设置为true,然后 reload 重新启动插件,然后打开vscode的output面板,选择eslint。
    • 这个方法不仅能看到eslintrc的日志,还能看到整个eslint的日志,相当于 DEBUG=*
    • 也适用于我们想快速验证和调试(而不是通过命令行启动eslint)一个新的规则在某个文件中的运行情况。

3. 如何快速debug分析eslint执行逻辑?

eslint生态基本上都可以通过 DEBUG 变量来调试eslint运行日志。如果不确定DEBUG的名称,可以先直接使用 DEBUG=* 运行,然后选择关注自己想要关注的部分。比如:我想关注 eslint-importresolver 的性能,我可以运行:

DEBUG=eslint-import-resolver-typescript,eslint-plugin-import:resolver:* npx eslint ./src/index.ts 
  1. 如何详细debug分析eslint执行逻辑?如果通过日志也无法分析具体问题,除了看源码,我比较喜欢方式是直接debug运行代码,参考node debug。可以运行:
node --inspect-brk ./node_modules/eslint/bin/eslint.js src/index.ts

然后我们可以在chrome上一行一行的对源码进行调试,也可以使用perfomance面板直接记录火焰图,分析性能问题。

实战: import/no-cycle性能问题排查

这个是排查pre-commit的过程中发现的一个问题:import/no-cycle 的性能十分的差,几乎占到所有的时间。

通过node debug和chrome的perfomance看板分析火焰图:

从火焰图可以看出:

  • lintFiles 其实就是某个规则检测这个文件所用的时间,发现红框区域的耗时是最久的。
  • 分析其调用栈,不难看出其一直在递归调用 processImportedModules,即使用例只引用了一个文件,但是调用栈已经很恐怖了。

归因到 import/no-cycle 的逻辑,他其实会递归的分析到的文件的的所有模块都解析一遍。

通过分析其源码发现,其实这个规则的逻辑会通过ignoreExternal 这个配置进行剪枝,从而优化性能。

所以,优化方式也很简单,对 import/no-cycle 规则配置开启 ignoreExternal,该配置会忽略非本项目之外(外部模块)的循环依赖检测(比如node_modules里的依赖、workspace的其他pkg)。


近期有不少 HC,感兴趣的同学可联系我内推!近期有不少 HC,感兴趣的同学可联系我内推!近期有不少 HC,感兴趣的同学可联系我内推!


Tecvan
All or nothing, now or never 👉