你有没有遇到过这种场景:CI 上 ESLint 跑了 5 分钟,最后报了一个“no-unused-vars”,你一脸问号:就这?

ESLint 其实干的活比你想的多:找文件、读配置、解析代码、建 AST、跑规则、收集问题、还能顺手给你修一部分。它慢,很多时候不是“它摆烂”,而是“它真在干活”。

一句话版本:ESLint 在做什么?

把 ESLint 想象成“代码体检”就行:

  1. 把源代码变成 AST(抽象语法树)
  2. 用一堆规则(rules)去遍历 AST,找到问题
  3. 输出诊断结果(可选:自动修复一部分)

你看到的每一条报错,本质上就是:“在某个 AST 节点上,这条规则觉得你写得不对。”


ESLint 的完整流水线(从命令到报错)

先上一个总览流程图,后面逐段拆开。


1) 入口:CLI / IDE 触发

两种常见入口:

  • 你在终端跑:npx eslint .
  • 你在编辑器里保存文件,IDE 插件后台跑(你看到红线/黄线)

这俩本质都是“调用 ESLint 引擎”,只是入口不同,默认参数可能不一样(比如 IDE 往往只 lint 当前文件,CLI 会扫整个项目)。


2) 配置加载:flat config vs .eslintrc

ESLint 首先要搞清楚:“你想让它按什么规则检查?”

目前你可能会遇到两种体系:

2.1 传统:.eslintrc.*(层层合并)

比如 .eslintrc.js/.json,特点是:

  • 支持 extends 一层层叠加
  • 支持 overrides 按文件类型覆盖
  • 支持“从当前目录往上找”,一路找到项目根

2.2 新的:flat config(eslint.config.js)

flat config 的心智更直:

  • 配置就是一个数组:从上到下匹配、合并
  • 更偏“JS 代码配置”,可组合性更强
  • 很多新生态(尤其新版本插件)优先支持它

你不需要纠结“哪种更好”,你只要记住:ESLint 会先把配置解析成“最终规则集”,后面流程才能继续。


3) 文件收集:哪些文件要检查?

这一步决定了“ESLint 要跑多大范围”,也决定了你 lint 为啥慢。

ESLint 会综合这些来源来决定文件列表:

  • CLI 传入的路径 / glob(比如 eslint src / eslint "src/**/*.{ts,tsx}"
  • ignore(.eslintignoreignores、默认忽略 node_modules
  • overrides / flat config 的 file patterns(某些规则只对某些文件生效)

一个很现实的经验:你 lint 的文件越多,ESLint 越慢;你让它去扫大目录,它就会真的去扫。


4) 解析:从文本到 AST

ESLint 不直接“看字符串”,它要先解析成 AST。解析这一步由 parser 决定:

  • 默认 parser:Espree(支持 JS)
  • TS 常用:@typescript-eslint/parser
  • Vue/Svelte 等:往往有各自的 parser 或 processor

大概过程是:

  1. 读取文件内容(字符串)
  2. parserOptions(比如 ecmaVersionsourceType)解析
  3. 得到 AST

如果你看到那种报错:

Parsing error: Unexpected token

十有八九是 parser / parserOptions 不对,或者你 lint 了不该 lint 的文件类型。


5) 建立“变量与作用域”信息(scope)

很多规则并不只是“看一眼节点就结束”,它需要知道:

  • 这个变量在哪声明?
  • 在哪被引用?
  • 有没有引用但没用?
  • 是不是 shadow 了外层变量?

所以 ESLint 会在 AST 基础上构建 scope 信息(变量/引用关系)。

这也是为什么 no-unused-vars 这种规则看起来简单,实际上要做不少分析工作。


6) 跑规则:rules 如何工作?

核心来了:ESLint 的 rule 本质是一个“AST 监听器”。

它一般长这样:

1
2
3
4
5
6
7
8
9
10
11
module.exports = {
meta: { type: 'problem', fixable: 'code' },
create(context) {
return {
Identifier(node) {
// 看到某个节点就做检查
// 满足条件就 context.report(...)
},
};
},
};

你可以把它理解成:

  • ESLint 遍历 AST
  • 走到某种节点(比如 CallExpression)时,把节点扔给对应 rule 的处理函数
  • rule 决定要不要报错、报什么、能不能修

rule 的输出是什么?

每条问题通常包含:

  • 文件名、行列号
  • rule 名(比如 no-undef
  • message(告诉你哪里不对)
  • severity(warn / error)
  • 可选:fix(怎么改)

7) 修复:–fix 到底做了什么?

当你跑:

1
eslint . --fix

ESLint 会在拿到规则给出的 fix 后,按一定策略把修复应用到源文本上,然后回写文件。

注意:不是所有规则都能 fix。

  • 有些是“可安全自动修复”的(比如补分号、调整引号、简单替换)
  • 有些是“需要你做决定”的(比如逻辑改写、可能影响行为)

所以 --fix 更像“能修的我帮你修”,不是“一键全修复”。


8) 输出:formatter + 退出码

最后 ESLint 会把结果格式化输出(终端表格、json、stylish 等),然后决定退出码:

  • 0:没有 error(注意:有 warn 也可能是 0,取决于配置)
  • 非 0:有 error,或触发了 --max-warnings

这就是为什么 CI 里经常会配:

1
eslint . --max-warnings 0

意思是:别给我“警告也算过”,要么干净,要么挂。


ESLint 为什么会慢?(最常见 4 个原因)

  1. 扫的文件太多:路径太大、glob 太宽、ignore 没写好
  2. TS 语义规则开太多:尤其是带 type 信息的规则,会读 tsconfig,成本很高
  3. 插件规则太重:有些 rule 本身就复杂
  4. 没用缓存:同样的文件每次都全量 lint

如果你是大项目,建议至少了解一下缓存:

1
eslint . --cache

它能让“没变的文件”少跑很多。


结尾

ESLint 的检查过程其实就是一条流水线:配置 → 找文件 → 解析 AST → 跑规则 → 输出/修复

你把这条链路想明白了之后:

  • 遇到误报:你会去查 parser/config/rule,而不是盲目关规则
  • 遇到慢:你会先收敛文件范围、再看 TS type-aware 规则、再上 cache
  • 需要定制:你也知道“写 rule”本质就是“写 AST 监听器”

Happy Coding!