前言
最近在帮助团队做一些基础工具进行规范数据采集,上层自己抽象一个 cli 工具,底层依赖一些工具做代码分析。底层的工具包括 ESLint、Stylelint、TSC 等,当然也可以是任意场景下对项目代码进行分析的库。只要按一定的格式输出数据,写入文件系统,工具会通过一定的方式将数据进行存储,最后对数据进行可视化。
在这过程中,我需要调用 ESLint Node.js API,加载项目配置,合并工具自定义的动态配置(随时可修改,不依赖工具发版的一份配置),对代码进行 lint。听起来这个工具并不复杂,但是在这过程中,我却遭遇到了很多 ESLint 的配置问题。
问题1:ESLint couldn't determine the plugin "@typescript-eslint" uniquely.
前一个小时 CI job 进行 ESLint 检查没有任何问题,在后续的代码变更中突然开始报错,影响到了 CI 其他构建任务。
问题2:Error while loading rule '@typescript-eslint/no-unnecessary-type-assertion': You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.
根据报错信息提示我们需要配置 parserOptions.project,但是实际上项目配置是有的,所以这个报错信息非常晦涩。
问题3:Configuration for rule "import/no-duplicates" is invalid: Value {"prefer-inline":true} should NOT have additional properties.
在项目的 lint 流程中正常,而在使用 ESLint Node.js API lint 代码时报错。
当然除此之外还有一些莫名其妙的问题,让我一度怀疑 ESLint 的 DX,在与其搏斗了一段时间,决定还是要去研究下其架构和一些源码。
很多时候,我们对一些事物产生误解和自以为是的推断,妄下结论,源于我们对其的认知实在太少,讲难听点就是无知。
通过这次的经验,我承认我对 ESLint 确实了解太少。虽然最后问题都能解决,但是我没有很好的 debug 手段,对其内部运作了解也不够,所以下次遇到新问题可能还是很难下手。
至于前面提到的问题,我会在文后给出答案,下面进入正题。
重新理解 ESLint
从事 Web 前端开发这个领域,应该或多或少都听说过或者使用过 ESLint,看官方的介绍:
Find and fix problems in your JavaScript code,ESLint statically analyzes your code to quickly find problems. It is built into most text editors and you can run ESLint as part of your continuous integration pipeline.
总结下就是:ESLint 通过静态分析找到 JS 代码中的问题,并且修复问题。 它集成在大多数文本编辑器中,并且可以在持续集成的流水线中使用。
配合不同的 parser,ESLint 也可以检查 TS、TSX、JSX、Vue 等代码中的问题。
ESLint 开箱即用,你不需要配置任何三方的插件或者自定义规则也可以运行,因为其内置了非常多的规则。它的自定义规则定义在源码 lib/rules下,目前大概有 300 条规则左右:
我们都知道 ESLint 开启规则,主要两种告警级别:error 和 warn。所以,这里我思考的第一个问题,什么规则应该用 error?什么规则用 warn?
以前,我们团队代码规范更多是使用三方插件,一般都是默认跟随三方插件的配置,它配置什么告警级别就是是什么。最多根据项目情况,对一些规则进行微调,但是对于告警级别怎么划分依然是模糊的。
当我作为团队规范小组成员,重新再梳理和制定更强规范的时候,我不得不重新思考这个问题。回到 ESLint 要解决的问题:Find problems in JavaScript code,再回到团队规范本质要解决的问题:就是尽可能在开发阶段,通过工具帮我们找出代码中比较严重的缺陷,这些缺陷可能会引发运行时的代码报错。
所以,思路一下清晰起来。通过摸排和调研团队中历史发生过的线上问题,并且调研团队中的小伙伴在日常写代码时因为粗心大意容易造成线上问题的情况,结合社区给到的方案,很快草拟了新规范的 RFC。下面简单介绍一些规则:
- @typescript-eslint/no-explicit-any,
error,团队从建立规范以来都在强调或者 code rview 阶段避免在代码中使用any,any带来的问题除了绕过类型编译器的检查,也会导致代码的阅读性下降,源头上一个any类型数据,会导致下游的代码逻辑都是在消费没有类型的一个数据 - import/no-extraneous-dependencies,
error,隐式依赖是复杂的项目容易引入的问题之一,它引发的问题是当项目中多个三方包依赖这个模块,它们之间可能存在一个版本的差异,如果直接在项目源码中使用,非常依赖 bundler 和模块管理器的处理策略来决定你构建后的产物。假设不小心升级一个三方包导致隐式依赖升级,那么你的产物可能隐性地发生了变更,直到出现线上问题,你可能才后知后觉 - consistent-type-assertions,
warn,保证项目中断言的姿势一致,同时也可以禁止在代码使用as等断言。在大多数静态语言中,都提供类断言的语法,因为一些场景下确实编译器会出现推导类型失败的情况,必须通过类型断言告诉编译器。然而断言还是带来潜在的风险,相当于告诉编译器:我自己保证类型安全,你不用管。一旦出问题,就是运行时报错。我们在编写代码时,还是尽量使用类型收窄去让编译器帮我们推导出正确的类型
限于篇幅就介绍这几个规则,any和 隐式依赖是代码中非常不安全的问题,而且也不应该有任何理由去引入。而类型断言短期来说还是有存在的理由,但是依然可能是代码中的威胁,不排除后续规范会提升告警级别。
当然,除此之外,我们也开始结合自己的业务场景和规范要求,开始自定义一些规则。例如:
- no-import-deprecated, 通过配置方式不允许在项目中调用废弃的 API,一般用于公司二方包重大升级 API 时过渡阶段
- no-literal-color-string,不允许在代码内联样式中直接使用 UED 团队定义的标准颜色变量字面量值,而是用组件库导出的标准变量
除此之外还有一些更加特定的规则,这里不一一介绍。
结合 ESLint 可扩展性,我们的规范推进不在只停留在文档方式或者约定式地方式推进,这把主动权完全交给研发,非常不可控。通过数据可视化地形式,我们也能很快地知道哪些项目规范情况,让团队负责人根据数据情况进行更好地决策。当配合 UED 团队推进 UI 规范专项时,数据也可以帮助他们更好地去推进或者把控节奏。
V8 vs V9
目前 ESLint 最新版本已经到 9.x(写该文时最新版本是 v9.1.1),而我们团队还在用 8.x 版本,因此后续的架构和源码解读都是基于 8.x 版本。当然,对于核心模块的设计,9.x 没有太大的变化。
下面介绍下 9.x 版本的一些比较大的 breaking change:
- 新的配置系统( Flat config ) ,在 8.x 版本,ESLint 配置系统基于 .eslintrc.* 配置,9.x 升级为 eslint.config.* ,在 9.x 版本还是可以使用 rc 的配置格式,但是需要再配置中设置ESLINT_USE_FLAT_CONFIG 变量为
false。新的配置最大作用就是解决旧的 rc 配置系统设计过于复杂的问题,而新的配置会更加扁平简洁 - 9.x 版本要求更高的 Node.js 版本,package.json 配置为:
^18.18.0 || ^20.9.0 || >=21.1.0" - 移除内置的大量 formatter 实现,通过单独的 NPM 包提供,减少 ESLint 安装体积
- 其它就是一些规则和 API 废弃以及优化
想了解更多的更新细节,可以参考文章:whats-coming-in-eslint-9.0.0 。
ESLint 架构
聊完了 ESLint 解决的问题,我们来了解其架构。首先看一个架构图:
紫色块是 ESLint 源码中比较关键的功能模块,下面一一进行简单的介绍:
-
bin/eslint.js,它是 eslint 命令入口,也就是我们在项目中执行:
eslint . --ext .js,.jsx,.ts,.tsx ./src的入口,其实就是一个bootstrap的入口,最关键的是背后cli.js调用。而eslint --init命令是一个单独的命令,为项目进行eslint配置初始化 -
lib/api.js, 它只是一个 eslint 核心 API 的重导入和导出,主要导出了
Linter、RuleTester、SourceCode、ESLint等核心的类 -
lib/cli.js,
eslint cli实现的核心,根据不同的cli参数执行不同的功能逻辑,将这部分实现抽离出来,使得开发者可以通过 Node.js API 的方式使用 ESLint。内部核心逻辑是调用cli.execute方法,代码文件的读取和 ESlint 数据输出也是在这里完成处理 -
lib/cli-engine,这个部分核心逻辑就是加载项目的 ESLint 配置,包括处理配置中的
plugins、parser、formatter、extends等合并逻辑,处理配置的逻辑依赖了一个官方的包:@eslint/eslintrc -
lib/linter,通过一个
Linter类封装了对代码进行 lint 的核心逻辑,它的内部实现不涉及到任何文件 IO,接收的主要参数是代码字符串,因此我们可以通过 Node.js 脚本直接使用该方法 -
lib/rule-tester,该模块基于 Mocha 封装了一个
RuleTester类,使得开发者能更高效便捷地编写规则的单元测试,而且这些测试可以在任何测试框架的runner中运行 -
lib/source-code,
SourceCode主要是通过项目配置的parser将项目源码转换成 AST ,默认使用estree,可通过配置parser使用其他的 ASTparser库,最后通过traverser的方式将所有的 AST 节点提供给rule消费 -
lib/rules,定义了 eslint 所有内置的规则,目前在 8.x 版本大概有 300 条左右规则
总体来说,ESLint 的架构还是不算复杂,功能模块设计非常清晰。基于插件或者规则配置的形式,将规则的开发权更多外包给了社区,不同的团队或者独立开发者可以根据自己的需求去编写规则。所以目前在社区,一些框架也会通过插件的方式开发一些降低开发者使用负担的 ESLint 插件,例如 React 官方提供的:eslint-plugin-react-hooks。
回答开篇的问题
问题 1
ESLint couldn't determine the plugin "@typescript-eslint" uniquely.
这个问题也可能出现在任何 eslint插件加载时,本质原因是,我们在配置一些 extends或者 plugins时加载了同一个插件,而这个插件从不同的文件位置加载。例如我们配置了 a 插件或者 b ,而 b 也依赖了 a,此时可能 b 加载 a 时从 b 的 node_modules 来,而 a 可能直接从项目的 node_modules 来,此时 eslint 就不知道应该使用哪个。
解决方案:
- 尽量避免配置同一个插件多次,注意插件依赖其他插件的问题
- 在
eslint配置中增加root: true,告诉 eslint 从项目根目录加载插件
问题 2
Error while loading rule '@typescript-eslint/no-unnecessary-type-assertion': You have used a rule which requires parserServices to be generated. You must therefore provide a value for the "parserOptions.project" property for @typescript-eslint/parser.
在我第一次遇到这个问题时,发现原因并不是简单的错误信息描述里问题,本质原因对于 eslint 不同的规则要么是 for JS 代码 ,要么是 for TS 代码。如果是 for TS 代码,需要配置 @typescript-eslint/parser,而这个 parser需要消费一份TS配置。上面的报错提示,就是在加载 @typescript-eslint/no-unnecessary-type-assertion规则是找不到 TS 配置。
我门的项目出现这个问题,是因为我抽离了一份共享的 eslint config a,然后 a 里面依赖了一些 eslint 插件,在加载某些规则因为加载顺序问题导致解析规则时缺少了 parserOptions.project 。
解决方案:
- 在加载 TS 的相关规则时,一定要注意必须得配置选项,可以参考着下面的配置来:
{
// ...
parser: '@typescript-eslint/parser',
plugins: ["@typescript-eslint"],
overrides: [
{
files: ['*.ts', '*.tsx'], // Your TypeScript files extension
extends: [
'plugin:@typescript-eslint/recommended',
],
parserOptions: {
project: ['./tsconfig.json'],
},
},
],
// ...
}
- 特别是在抽离公共 eslint 配置时,需要严格保证必要的配置项
问题 3
Configuration for rule "import/no-duplicates" is invalid: Value {"prefer-inline":true} should NOT have additional properties.
这个问题出现的原因是在同一个项目中,安装了同一个 eslint 插件多个版本,本质是一个依赖冲突的问题。比如我提取了一份 eslint config b 给多个项目共享,b 依赖了一个高版本 a 插件,并配置了 a 的一些规则。而项目本身也装了 a 插件,但是版本比较低。eslint 在加载插件时,优先加载项目的低版本插件 a,导致在消费规则时缺少了高版本的配置项而报错。
解决方案:
- 移除低版本的插件依赖,既然已经通过 b 来共享配置和依赖,就不需要项目本身再安装一次 a 插件依赖
在了解了更多的 ESLint 架构和源码知识,我个人会觉得其最复杂的功能部分是配置加载和配置合并部分的逻辑,例如上面我遇到的大多是问题,基本是因为插件加载或者配置问题导致的。
Debug 问题
如果在日常开发中大家遇到一些 ESLint 执行报错,可以通过--debug来进行调试,加了此选项,运行 eslint 时可以打印更多的关于 ESLint 内部运行时的关键日志:
但是这种方式打印出来的 log 太多,可以通过设置 scope DEBUG 变量只看某部分的日志,例如看加载配置相关的:
DEBUG=eslintrc:config-array-factory yarn lint
效果:
能比较清晰地通过日志看到你的配置和插件加载的位置、顺序以及依赖关系。
ESLint 源码中比较核心模块的 Debug scope:
-
eslintrc:* ,8.x ESLintRC 配置系统相关,然后会跟具体文件模块绑定,例如 eslintrc:cascading-config-array-factory
-
eslint:* ,ESLint 本身源码相关的日志,基本跟上面介绍的架构功能模块一一对应,例如:
- eslint:cli-engine,cli-engine 模块相关日志
- eslint:linter,linter 模块相关日志
总结
使用合适的工具帮助我们解决项目问题,并且结合公司的工作流,特别是如果能利用 CI 流程进行赋能,使得我们可以更好地对项目进行规范检查、代码分析、构建等。
依赖人的自觉或者约定式地规范、协作始终会不可避免地出现纰漏,人不是机器,能力再强的人也可能有粗心的时候,因此引入合适的工具尤为重要。
本文作为一个 ESLint 源码导读的开胃菜,下一篇文章我就深入源码,详细聊聊 ESLint 配置加载和处理的逻辑。