规范文档写得再全,落地一个月之后代码里还是四五种风格。原因不复杂,文档只是写在那儿,没有任何东西在拦。
ESLint 的价值不在于它有多少条规则,而在于它是少数能在保存、提交、构建三个环节把不合规代码挡回去的工具。这篇不逐字段讲配置文件的语法,那部分我另写了一篇;这篇只聊落地。一份最小可用的规则集长什么样、要不要用现成的 config 包、和 Prettier 怎么分工、--fix 能修到什么程度、lint-staged 怎么做增量、CI 上卡在哪一步、规则从松到严怎么推。那两份规则清单一条没删,放在后面当速查表,并且标注了哪些在新版本里已经改名或移除。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 三类规则的性质不同,该用什么态度对待
- 一份能直接抄进项目的最小规则集,以及每条为什么这么定
- 主流 config 包怎么选,airbnb、standard、typescript-eslint 各适合谁
- 和 Prettier 的分工线画在哪,为什么现在不推荐
eslint-plugin-prettier --fix能自动修什么,不能修什么,大规模修复怎么提交- lint-staged 做增量检查的完整配置和常见坑
- CI 卡口设在哪一步,
--max-warnings和--cache怎么用 - 规则严格度从 off 到 error 的推进节奏
- 一份带修正标注的常用规则速查表
- 迁到 ESLint 9 扁平配置之后,上面这些方案要改哪里
一、先分清三类规则
配置之前先分清楚 ESLint 的规则其实是三种不同性质的东西,混在一起谈就会吵不完。
第一类是「基本上写错了」。no-dupe-keys、no-unreachable、no-const-assign 这些,触发了几乎百分之百是 bug。这类规则没有讨论空间,全开 error。
第二类是「能跑但不该这么写」。eqeqeq、no-eval、no-param-reassign 属于这一档。它们有明确的理由,但也确实存在合理的例外,需要团队达成一致,个别地方允许带规则名的 eslint-disable。
第三类是纯格式。缩进、引号、分号、行宽,这一类完全是偏好问题,讨论它们的性价比最低。我的建议是整体交给 Prettier,ESLint 一条都别管,理由第四节展开。
分清这三类之后你会发现,团队里真正需要讨论的只有第二类,第一类照抄推荐集,第三类交给格式化工具。绝大多数关于「ESLint 配置」的争吵,都是在第三类上耗时间。
二、一份能直接用的最小规则集
先给一份能直接抄进项目的。这份规则不多,但每一条都是有实际约束力的。
'rules': { |
这份配置里有几条值得单独说说。
'no-var': 'error' 加上 'init-declarations': 2 是一组。前者禁掉 var,后者要求声明变量时就给初值。两条一起的效果是逼你把变量声明尽量往使用处挪,而不是在函数顶部先声明一堆空变量。老项目上这两条会炸出很多报错,可以先设成 1 观察。
'semi': ['error', 'never'] 是不写分号那一派。这个选择本身没有对错,但一旦定了就必须全项目统一,因为不写分号在少数场景下会踩到 ASI 的坑,比如下一行以 ( 或 [ 开头。团队里一半人写一半人不写才是最糟的情况。
'quotes': ['error', 'single'] 和 'indent': ['error', 2, {'SwitchCase': 1}] 是典型的第三类规则。SwitchCase: 1 这个子选项别漏,不写的话 case 默认和 switch 顶格对齐。
'complexity': [2, 9] 限制圈复杂度,一个函数里的分支超过 9 个就报错。这条我挺推荐开的,它是少数能拦住「一个函数写八百行」的规则。顺带一提,ESLint 9 把可选链 ?. 也算进复杂度了,老项目升上去会有一批函数突然卡线。
最后那两行被注释掉的 consistent-return 说明了一件事,用 TypeScript 之后有一批 ESLint 规则可以直接关掉,因为编译器管得更准。这个取舍在 TS 项目里很常见。
三、别从零开始,先挑一个 config 包
自己从零维护两百条规则是件吃力不讨好的事。规则会随着 ESLint 版本变动,改名的、弃用的、默认值改了的,靠人工跟不现实。更省事的做法是先 extends 一个成熟的配置包,再在上面增删十来条。
常见的几个选择,按我自己的使用感受列一下。
| 配置包 | 定位 | 我的感受 |
|---|---|---|
@eslint/js 的 recommended |
ESLint 官方最小推荐集 | 只包含第一类规则,零争议,任何项目都该垫在最底下 |
eslint-config-airbnb / airbnb-base |
规则最全最严 | 上手就是几百条报错,适合新项目从头立规矩,老项目慎用 |
eslint-config-standard |
观点很强,不写分号、两空格 | 省心,但风格是它说了算,改不动 |
typescript-eslint 的 recommended |
TS 项目基础 | TS 项目基本是必选,它会关掉一批被编译器覆盖的原生规则 |
eslint-config-next |
Next.js 项目自带 | 框架方维护,跟着框架版本走,省事 |
eslint-config-prettier |
不加任何规则,只关规则 | 和 Prettier 一起用时必装,而且必须放在最后一位 |
选的时候有个判断标准比「哪个更好」实用得多:看这个包最近有没有在维护、有没有出扁平配置版本。ESLint 9 之后没跟上的包,会把你的升级路堵死。
eslint-config-prettier 那一行要特别注意顺序。它的全部作用就是把前面所有包开启的格式化规则统统关掉,所以放在 extends 数组的最后一位才有意义。放中间等于白装,我见过好几次。
四、和 Prettier 的分工线画在哪
这块是最容易配错的地方,也是我态度最明确的一块。
分工线其实很清楚。Prettier 管排版,它把代码重新打印一遍,缩进、换行、引号、分号全部由它决定;ESLint 管代码质量,未使用变量、可疑写法、复杂度、框架用法。两边职责不重叠,才不会互相打架。
问题在于 ESLint 自己也有一大堆格式化规则,它们会和 Prettier 的输出冲突。你保存时 Prettier 把代码格式化成 A,ESLint 立刻报错说应该是 B,来回拉锯。eslint-config-prettier 解决的就是这个,它把所有可能冲突的 ESLint 规则关掉,让格式这件事只剩一个说话的人。
那 eslint-plugin-prettier 呢。它的思路是把 Prettier 当成一条 ESLint 规则跑,格式不符就报成 lint 错误。听起来很整齐,一条命令搞定所有事。
我一开始也是这么配的,用下来的感受是不太值。Prettier 官方文档现在也不推荐这种用法,理由主要有几条:整段格式差异会被报成一堆红色的 lint 错误,噪音很大;Prettier 跑在 ESLint 里比单独跑慢;编辑器里的报错体验也别扭,明明保存一下就能修好的东西,却一直标着红波浪线。
现在更推荐的做法是让 Prettier 单独跑一条命令。
{ |
编辑器里配好保存自动 Prettier,提交前 lint-staged 里两条都跑一遍,CI 上 format:check 和 lint 各卡一道。职责分开之后,出问题也好定位,是格式问题还是质量问题一眼就知道。
顺着上面聊,ESLint 自己对格式化这件事的态度也变了。从 8.53 开始,ESLint 把所有纯格式化规则标记为弃用,不再接受新特性和 bug 修复,官方把它们迁到了独立的 @stylistic/eslint-plugin。方向已经很明确,格式化 ESLint 不打算继续管了。所以如果你还在纠结 indent 和 quotes 怎么配,可以直接跳过这一步。
五、–fix 能修什么,不能修什么
eslint . --fix 是投入产出比最高的一个命令,但要知道它的边界。
能自动修的规则,官方文档里会标一个扳手图标。大致规律是「改动是确定的、不会改变语义」的都能修,比如引号、分号、prefer-const、no-extra-semi、多余空格。不能修的是那些「怎么改取决于你想干什么」的,比如 no-unused-vars,ESLint 不知道该删掉这个变量还是你忘了用它;再比如 complexity 超标,拆函数这事只能人来。
有几个参数配合起来很好用。
# 只看会修成什么样,不落盘 |
这里有个坑要注意。老项目第一次跑全量 --fix,改动动辄上千个文件,和业务代码混在一个提交里,review 根本没法看,出了问题也没法二分定位。正确做法是全量修复单独一个提交,提交信息写清楚这是纯机械改动,然后在 .git-blame-ignore-revs 里把这个 commit 加进去,免得以后 git blame 全指向它。
六、lint-staged 做增量检查
全量 lint 在稍大的项目上要跑几十秒,放进 pre-commit 钩子里没人受得了。lint-staged 的思路是只检查这次暂存的文件。
装上 husky 和 lint-staged 之后,package.json 里这么写。
{ |
lint-staged 会把匹配到的暂存文件路径追加到命令后面,所以命令里不用写路径。修复后的内容会被自动重新 git add,这一点它已经处理好了。
有两个坑我踩过。
一个是被 ignore 的文件。lint-staged 只按 glob 匹配文件名,它不知道你的 ESLint 忽略配置,所以可能把一个已经被忽略的文件传给 ESLint,ESLint 会输出一条「File ignored because of a matching ignore pattern」的警告。配了 --max-warnings 0 的话,这条警告足以让提交失败。ESLint 9 提供了 --no-warn-ignored 来关掉这个提示,加上就行。
另一个是顺序。eslint --fix 和 prettier --write 都会改文件,两条命令的先后会影响最终结果。我的做法是 ESLint 在前 Prettier 在后,让 Prettier 有最终话语权,这样和第四节说的分工是一致的。
还要说一句,pre-commit 钩子是可以被 --no-verify 绕过的,所以它只是提效手段,不能当卡口。真正的卡口在 CI。
七、CI 上把卡口设在哪一步
CI 里的 lint 和本地的 lint 目的不一样。本地是为了让你早点发现问题,CI 是为了保证没人能把不合规的代码合进主干。
- name: Lint |
--max-warnings 0 是核心。不加这个参数,warn 级别的规则永远不会让 CI 变红,那这些规则就等于没配。加上之后 warn 和 error 在 CI 上是同等效力,区别只在本地开发时的视觉噪音。
--cache 在中大型项目上省时间很明显,它会把上次检查通过的文件记下来,只重新检查变动过的。CI 上要配合缓存目录一起用才有意义,缓存文件本身记得加进 .gitignore。
还有一件事容易被忽略。ESLint 9 把一批格式化输出器从核心包里移走了,checkstyle、compact、junit、tap、unix 这些都要单独装 eslint-formatter-* 包。很多流水线用 --format junit 把结果喂给测试报告系统,升级之后直接报找不到 formatter,而本地跑的是默认的 stylish,根本发现不了。这个坑和其它升级变化我整理在 ESLint 9 升级攻略 里了。
八、规则严格度怎么一步步推上去
新项目从严开始很容易,难的是给一个跑了几年的老项目加规范。直接把 airbnb 那套压上去,跑出来两万条错误,团队第一反应就是把 lint 关掉。
我自己用的节奏是这样的。
先只开第一类规则,也就是 @eslint/js 的 recommended,配上 --max-warnings 0。这一档基本都是真 bug,改起来没有争议,团队接受度最高。
第二步引入 Prettier,全量格式化一次,单独提交。格式这块一次性解决完,后面就不用再讨论了。
第三步开始逐条加第二类规则,每次只加两三条,先设成 warn。用 --max-warnings 加上当前的警告数量做棘轮,允许存量存在但不允许新增。这个数字每次改完一批就往下调一格,直到调到 0,然后把规则提到 error。
第四步是分区域收紧。用 overrides 给新写的模块单独配一套更严的规则,老代码维持宽松。这样新代码从一开始就是干净的,老代码慢慢还。
"overrides": [ |
整个过程里有一条纪律要立住,eslint-disable 必须带具体规则名,禁止裸写。光秃秃的 /* eslint-disable */ 等于把整个文件从校验里摘出去,后面加进来的问题一个都发现不了。再配上 reportUnusedDisableDirectives,把那些代码改完之后已经不需要的 disable 注释也报出来,不然它们会越攒越多。
说实话这套节奏走完至少要几个月,中间还得有人持续盯着。但比起「一次性压上去然后被全员关掉」,这条路是真的能走通的。
九、常用规则速查表
下面这份是我当年整理的完整规则清单,逐条带中文说明,当速查手册用。
先提个醒,这份清单是 2018 年整理的,里面有一批规则在后来的 ESLint 版本里改名或者移除了,直接整份复制到项目里会报「规则不存在」。我在每段后面标了需要注意的条目。
另外它和第二节那份最小规则集在几个地方是矛盾的,比如这里 "semi": [2, "always"] 要求分号,前面那份是 'semi': ['error', 'never'] 禁止分号。这说明速查表就只是速查表,不是一份可以直接用的配置。同一份清单里 array-bracket-spacing、brace-style、camelcase、indent、quotes 这些键也出现了不止一次,真写进 JSON 的话后面的会静默覆盖前面的,不会有任何提示。
"no-alert": 0,//禁止使用alert confirm prompt |
这一段里有两条要改。no-empty-label 在 ESLint 2.0 就被移除了,它的功能并进了 no-labels;no-catch-shadow 是弃用规则,当年是为了绕开 IE8 的 catch 作用域行为,现在没有意义。另外 no-extra-semi 原注释写的是「禁止多余的冒号」,应该是分号,这里顺手改了。
no-console 设成 2 在如今的项目里偏严。更实用的写法是 ["warn", { "allow": ["warn", "error"] }],放行 console.warn 和 console.error,只拦调试用的 console.log。
"no-fallthrough": 1,//禁止switch穿透 |
这一段有个明显的错位。"linebreak-style": [0, "windows"] 被夹在一堆 no- 开头的规则中间,而且和第二节那份配置里的 ['error', 'unix'] 直接冲突。换行符这件事现在更推荐交给 Git 的 core.autocrlf 和 .gitattributes 处理,在 ESLint 里管反而会让跨平台协作变麻烦。
no-negated-in-lhs 也是弃用规则,替代品是 no-unsafe-negation,后者除了 in 还能覆盖 instanceof。no-mixed-requires: [0, false] 里的第二个参数 false 是很老的写法,现在应该传对象。
no-param-reassign 设成 2 我是支持的。直接改传进来的参数会让调用方莫名其妙拿到被改过的对象,尤其参数是引用类型的时候。想改就先复制一份。
"no-path-concat": 0,//node中不能使用__dirname或__filename做路径拼接 |
这一段里 no-undefined 设成 2 需要斟酌。它禁止把 undefined 当标识符用,理由是在 ES5 之前 undefined 可以被重新赋值。现在这个风险早就不存在了,而 TypeScript 项目里 x === undefined 是非常自然的写法,一刀切禁掉反而别扭。
no-restricted-modules 已经从核心里移走,和上一段的 no-path-concat、no-process-env、no-sync 一样,现在归 eslint-plugin-n 管。no-native-reassign 改名成了 no-global-assign,no-spaced-func 改名成了 func-call-spacing。
no-warning-comments 那条配置挺有意思,它会把代码里的 TODO、FIXME、XXX 报成警告。设成 1 是合理的,报成 error 的话没人受得了。我一般不开它,改用 CI 上单独统计 TODO 数量,避免它混在 lint 输出里。
下面是风格相关的一批。
"array-bracket-spacing": [2, "never"],//是否允许非空数组里面有多余的空格 |
这一段几乎全是第三类规则,也就是纯格式。按第四节说的分工,indent、max-len、key-spacing、comma-spacing 这些交给 Prettier 就好,在 ESLint 里配它们只会和格式化工具打架。
camelcase 是这里少数值得留下的。它管的是命名而不是排版,Prettier 不会碰。不过后端接口返回 snake_case 字段的场景很常见,实际用的时候一般要加 { "properties": "never" },只管变量名不管对象属性。
consistent-this: [2, "that"] 是箭头函数普及之前的产物,现在可以直接关掉。
最后一批。
"new-parens": 2,//new时必须加小括号 |
收尾这段里有三条已经不能用了。space-after-keywords 和 space-return-throw-case 在 ESLint 3 时期被合并进了 keyword-spacing,prefer-reflect 也已经移除。valid-jsdoc 在 ESLint 9 里同样被删掉,要做 JSDoc 校验得装 eslint-plugin-jsdoc。
quote-props: [2, "always"] 那条注释写的是「强制双引号」,其实这条规则管的是属性名要不要加引号,加什么引号是 quotes 管的。这是两件事。
strict: 2 在 ES Module 项目里没必要,模块代码天然处于严格模式,再写 'use strict' 反而会被报成多余。
十、迁到扁平配置之后要改什么
上面这套落地方案的思路在 ESLint 9 上完全成立,但写法要调。ESLint 9 把扁平配置(eslint.config.js)设成了默认,.eslintrc.* 不再被读取。
改动集中在这几处。
配置文件名换成 eslint.config.js(或者 .mjs/.cjs),内容从一个对象变成一个数组,数组里每一项按顺序合并,后面的覆盖前面的。
.eslintignore 不再被读取,内容要搬进一个只有 ignores 的配置项。这一条最容易漏,症状是 dist/ 突然开始被检查,CI 上炸出成千上万条错误。
env 预设没有了。以前 env: { browser: true } 一行的事,现在要装 globals 这个包,写成 globals: { ...globals.browser }。忘了这步的典型症状是满屏 'window' is not defined。
extends 的用法变了,不再是字符串数组,而是把配置对象直接展开进数组。所以第三节讲的那些 config 包,得看它有没有出扁平配置版本。没出的可以用 @eslint/eslintrc 提供的 FlatCompat 先顶着,但那只是过渡手段。
--ext 参数的行为也变了。检查范围现在由配置里的 files 字段决定,package.json 和 lint-staged 命令里那句 --ext .js,.ts 需要重写。具体到你装的那个 9.x 小版本怎么处理这个参数,跑一次 npx eslint --help 看输出最准,别照抄网上的老文章。
overrides 这个字段也没了,因为数组里多写一项带 files 的配置就是 override。
把前面几节的方案翻译过来,大概长这样。
// eslint.config.js |
eslint-config-prettier 放最后这条规矩没变,只是从「数组最后一个字符串」变成了「数组最后一项」。
想把配置文件里每个字段的含义搞清楚,可以看我另一篇 ESLint 配置文件详解,那篇把 env、parserOptions、plugins、extends、overrides 逐个拆开讲了,也附了新旧字段的对照表。想直接抄一份跑在 Next.js 上的完整配置,看 基于 ESLint 9 配置前端开发规范。
总结
回到开头那个问题,规范落不了地,问题从来不在规则表不够长。
真正起作用的是这么几件事。规则按三类分开对待,第一类照抄官方推荐集,第三类整体交给 Prettier,只在第二类上和团队讨论。格式化和代码质量分成两条命令跑,别用 eslint-plugin-prettier 把它们搅在一起。pre-commit 用 lint-staged 做增量提效,CI 上用 --max-warnings 0 做真正的卡口。存量项目靠 --max-warnings 当棘轮一格一格收紧,配合 overrides 让新代码从一开始就干净。
规则清单本身反而是最不重要的部分。后面那份速查表的正确用法是查,不是抄,里面已经有一批规则在新版本上跑不起来了。
如果你现在要从零起一个项目,我的建议很简单:@eslint/js 的 recommended 打底,加上框架方提供的 config 包,末尾挂 eslint-config-prettier,自己只写十来条真正在意的规则。剩下的精力花在 CI 卡口和推进节奏上,比调规则值钱得多。