大前年底我们在升级一个基于 pnpm 的 Monorepo 项目时,决定直接把 ESLint 从 v8 升级到 v9.15.0(2024年11月发布的最新稳定版)。原因在于旧的 .eslintrc.json 配置在多个层级的 packages 中产生了严重的配置继承混乱,且启动检查时的层级查找逻辑让冷启动时间经常超过 2 秒。ESLint v9 力推的扁平化配置(eslint.config.js)正是为了解决这一性能与清晰度问题。
迁移的第一步是理解配置结构的根本性变化。旧的层级查找被彻底废弃,取而代之的是一个导出的数组,数组中的每一项代表一个配置对象。我在迁移我们那个核心交易服务(单包代码文件超过 1200 个)时,遇到的第一个实际问题就是插件兼容性的断裂。
@typescript-eslint 的解析器在 v8 环境下默认导出的配置结构无法直接塞进新的数组格式。我尝试直接运行,得到了 ESLint couldn't find the plugin "@typescript-eslint" ... 的错误。排查后发现,v9 要求插件必须显式导入,而不是通过字符串自动解析。解决方案是修改导入方式,并配合 tseslint.config 辅助函数。
以下是我们最终在生产环境跑通的核心配置片段,针对 TypeScript 和 React 项目进行了适配:
在迁移过程中,我注意到 ESLint v9 对 ignores 的处理逻辑变了。在旧版中,.eslintignore 里的 build 会忽略所有目录下的 build 文件夹。但在扁平化配置中,ignores 必须放在一个独立的配置对象中,且如果放在数组末尾,它会影响所有前面的配置。如果不这么做,你会发现 build 目录下的临时文件依然会被检查,导致大量的解析错误。
性能上的提升是立竿见影的。迁移前,全量检查 1200 个文件耗时约 8500ms;迁移后,得益于扁平化配置去除了复杂的目录遍历,耗时降到了 6200ms 左右。如果配合后面章节提到的 lint-staged,开发体验的提升会更加明显。
在我们的社区系统(日均活跃用户 120 万,前端代码量约 8 万行)中,曾经遇到过一次严重的开发阻塞。起因是有位新同事在提交代码时,由于 Husky 的 pre-commit 钩子配置了全量 ESLint 检查,导致他每次提交都要等待将近 40 秒。在一次大版本迭代中,这种等待累积起来严重拖慢了上线节奏。原因在于当时的配置没有区分暂存区文件,直接运行了 eslint . --ext .js,.ts,.tsx。
解决方案是引入 lint-staged。它的逻辑非常清晰:只检查当前 git add 加入到暂存区的文件。对于大型项目,绝大多数提交只涉及几个文件的修改,全量检查是极大的资源浪费。
我们在 2024 年 10 月升级到了 Husky v9.1.7,这个版本简化了初始化流程,使用 husky init 即可快速生成 .husky 目录。配置 lint-staged 的关键在于 package.json 中的脚本定义。
以下是我们目前在生产环境使用的配置,针对 ESLint v9.15.0 和 Prettier 3.4.1 进行了适配:
同时,.husky/pre-commit 文件的内容需要精简,直接调用 npx lint-staged:
为了验证优化效果,我做过一次具体的数据对比。在一次涉及 15 个文件的特性分支提交中:
eslint .,扫描了约 1800 个文件,耗时 3800ms。性能提升了 30 倍以上。原因在于 lint-staged 绕过了大量的文件 I/O 和解析开销。
这里有个细节需要注意。有一次线上接口突然变慢,排查下来发现是某个同事为了省事,在代码里加了大量的 eslint-disable 注释,导致 ESLint 规则失效,最终引入了一个逻辑死循环。因此,我在 lint-staged 的 ESLint 命令中加入了 --report-unused-disable-directives 参数,如果有人写了无用的禁用指令,提交时会被直接拦截。这比在 CI/CD 流水线中才报错要高效得多,毕竟在本地就能阻断“坏代码”进入仓库。
很多团队在引入工程化规范时,最头疼的问题不是配置工具,而是 ESLint 和 Prettier 打架。我在负责一个遗留系统重构时,就遇到过这种情况:ESLint 要求代码必须加分号,而 Prettier 的配置却是去掉分号,导致保存代码时 Prettier 把分号删了,一保存 ESLint 又自动加上,陷入了死循环。
原因在于两者的职责边界不同。Prettier 3.4.1 是一个 Opinionated(自以为是)的格式化工具,它只关心代码的长相(缩进、引号、分号),不关心逻辑对错。而 ESLint v9.15.0 除了检查逻辑(如禁止 var、未使用的变量),也包含一部分格式化规则(如 indent, quotes, semi)。当两者的格式化规则不一致时,冲突就产生了。
解决方案不是去一个个修改规则对齐,而是利用 eslint-config-prettier 彻底关闭 ESLint 中的格式化规则,让 ESLint 专心负责代码质量,Prettier 专心负责代码美观。
具体的实施步骤在我们的订单系统中是这样操作的。首先安装依赖:
关键在于配置文件的加载顺序。在 ESLint v9 的扁平化配置中,必须确保 eslint-config-prettier 是最后一个被应用的配置。我在第一章给出的 eslint.config.js 中已经体现了这一点:
如果不这么做,比如你把 prettierConfig 放在了中间,那么后面加载的其他插件可能会重新开启某些格式化规则,导致冲突复现。
除了配置层面的解决,我还遇到过一个隐蔽的问题。有一次我们发现某个文件在提交时总是被 Prettier 修改,但 ESLint 却不报错。排查后发现,是因为该文件里有一行超长的字符串,ESLint 的 max-len 规则和 Prettier 的 printWidth 设置不一致。解决方案是直接在 ESLint 配置中移除 max-len 规则,完全交给 Prettier 的 printWidth: 100 来控制。
对于编辑器集成,我建议团队统一使用 editor.formatOnSave 配合 ESLint 插件。在 VSCode 的 settings.json 中,我的配置如下,确保保存时先执行 ESLint 修复逻辑问题,再由 Prettier 处理格式:
这种分工明确的策略,让我们在重构那个有 5 年历史的老项目时,新增代码的风格和质量得到了统一,且没有引入额外的工具链复杂度。
去年我们团队在重构一个跑了 4 年的老后台系统时,我翻了翻它的 package.json,发现里面还躺着 tslint 和一堆已经不维护的 @typescript-eslint/eslint-plugin 旧版本。当时我就意识到,这个项目的代码检查工具链已经彻底脱节了。那会儿我们正准备把它迁移到 pnpm Monorepo,第一步就是得把 Linter 这层理顺。
现在回过头看,前端 Linter 的选型其实已经走过了三个阶段。最早大家用 TSLint,那是 TypeScript 刚火起来的时候唯一的官方选择。但 TSLint 有个致命问题:它只认 TypeScript,你写个 JS 文件它就不管了。我们那个老项目里就有这种情况,有些工具函数是 .js 写的,TSLint 直接跳过,导致里面有个 var 声明混在里面好几年没人管。后来 ESLint 社区通过 @typescript-eslint 这个解析器把 TS 吃下来了,TSLint 也就在 2019 年宣布废弃。如果你现在新开项目还在看 TSLint 的文档,那真的是在考古了。
现在的主流肯定是 ESLint v9.15.0 配合 Prettier v3.4.1。我之所以坚持用这套组合,是因为它们分工明确。ESLint 管逻辑质量,Prettier 管代码长相。我们团队之前有个争议,有人说既然 Prettier 能格式化,为啥还要 ESLint 的格式化规则?我直接给他们看了一段代码:
这段代码 Prettier 格式化完漂漂亮亮的,但 ESLint 结合 @typescript-eslint 能告诉你:var 不应该用,应该用 const;data 可能是 null,你得先处理;还有 order.data 如果不存在,items 就是 undefined。这就是两者的核心区别:Prettier 不关心你代码会不会崩,它只关心缩进是不是 2 个空格。
不过,今年社区里吵得挺凶的是 Oxc 和 Biome。这两个都是用 Rust 写的,速度确实快得离谱。我上个月在一个 30 万行代码的 Monorepo 里试了下 Oxc 的 Linter 原型,解析速度比 ESLint 快了差不多 15 倍。但现实是,我们项目里依赖了大量的自定义 ESLint 插件,比如我们自研的那个检查 API 调用规范的插件,还有跟公司内部的代码规范平台对接的规则。Oxc 现在还没法支持这些自定义逻辑,所以目前我只会用它做本地快速检查,生产环境还是跑 ESLint。
关于 ESLint v9 的扁平化配置,我刚开始迁移的时候确实有点烦。以前我们习惯在子目录放 .eslintrc.js 继承根目录配置,现在 v9 强制用 eslint.config.js,整个配置是一个数组,直接扁平化了。我们那个 Monorepo 有 5 个子包,迁移过程大概花了半天。好处是现在配置查找快了,而且你一眼就能看清楚哪些规则在哪个层级生效。比如我们针对测试文件放宽了一些规则,现在是这样写的:
这个配置我跑了好几次才调对,特别是 ignores 的位置,v9 里必须放在数组最后或者单独一个对象,不然不生效。
至于未来趋势,我觉得 AI 辅助修复 这块会是个大变化。我们现在用 --fix 只能修格式和简单逻辑,但像“这个 useEffect 依赖项没写全”这种问题,AI 结合上下文其实能给出更精准的修复建议。另外,Monorepo 的增量检查也是刚需,我们现在的 pnpm 项目,每次提交只检查改动包及其依赖,这比全量跑快了 70% 左右(从平均 8 秒降到 2.4 秒)。
这事得从今年 3 月份说起。我们那个负责实时推送订单状态的 Node.js 微服务,在大促期间突然开始 OOM(内存溢出),重启后大概 20 分钟又挂。当时 QPS 其实不算高,也就 800 左右,但内存占用从启动时的 120MB 一路飙到 1.4GB。
我上去排查,先看了 process.memoryUsage(),发现 heapUsed 一直在涨。用 node-memwatch 打了个快照,对比后发现有个 Map 对象一直在变大。顺着代码找过去,问题出在一个 WebSocket 连接管理的模块里。
那个模块是两年前写的,当时为了图省事,直接在全局挂了个连接池。代码大概长这样:
看到没?这个 connections 是个全局的 Map,只有在 error 事件时才删除。如果用户正常关闭连接(比如关掉浏览器),close 事件没监听,这个 Map 里的引用就永远留着。而且 WebSocket 对象本身还带着缓冲区,每个连接大概占 2-3MB 内存。那天大促,用户频繁刷新页面,连接不断新建,旧的又没释放,内存不爆才怪。
为什么 Lint 没拦住? 因为当时这个项目还没强制开启 @typescript-eslint/no-floating-promises 和自定义的生命周期检查规则。而且这种逻辑错误,Prettier 肯定不管,ESLint 默认规则也不管。我们后来加了一条自定义规则,专门检查这种全局缓存有没有对应的清理逻辑。
修复其实很简单,但当时线上压力太大,我们是用临时方案先止血的:
改完上线,内存曲线立马平稳了。但这次事故让我意识到:代码能跑和代码安全是两回事。后来我在团队的 ESLint 配置里加了一条硬规则:所有全局变量、模块级缓存,必须有对应的清理机制,否则直接阻断提交。
我们用 lint-staged 配合 Husky v9.1.7 做了个门禁。现在每次 git commit,只会检查改动的文件,速度很快(平均 300ms 左右)。配置大概是这样:
这次事故之后,我们还专门写了一个 ESLint 插件,用来检查我们业务里的一些特定模式。比如我们规定,所有 setInterval 必须用变量接收,且在组件卸载或模块销毁时要 clearInterval。这种规则 ESLint 本身没有,但自己写个插件也就几十行代码,却能防止很多低级错误。
现在回想起来,如果当时有现在的这套 Lint 机制,那个 connections 的问题在代码提交时就会被拦住。毕竟线上排查内存泄漏的成本太高了,你得复现、得打快照、得对比堆内存,折腾半天可能就为了改一行代码。而在提交前花 300ms 跑个检查,简直太划算了。
去年我接了个电商中台项目,DAU 过了百万,代码仓库却像座屎山。之前的技术负责人为了省事,把 ESLint 和 Prettier 都关了,结果团队里十几个人的代码风格完全凭心情。最离谱的一次,一个刚毕业的小伙子写了个没做空值判断的递归函数,直接在商品详情页炸了,导致主进程卡死,用户端的白屏率飙升。
我接手后的第一件事就是强制上 ESLint v9 + Prettier,并且用 Husky 卡死提交。
当时遇到最头疼的问题是 Husky 跑得太慢。仓库大,每次 git commit 都要等半分钟,开发们怨声载道。我排查了一下,发现 lint-staged 的配置太粗暴,直接全量扫描了。我改了策略,只针对这次提交的文件做增量检查,并且把 Prettier 的格式化单独抽出来,用 --cache 参数缓存结果。改动后,提交等待时间从 30 秒降到了 3 秒左右,这才算把大家的嘴堵上。
关于选型,我有点自己的看法:
* 别盲目追新:如果你的项目还在用 CommonJS 且比较稳定,没必要强行迁移到 ESLint v9 的扁平化配置,迁移成本很高,而且文档坑多。
* 适度原则:小项目或者个人博客,Husky 其实有点重,直接靠编辑器插件格式化就够了。
* 冲突处理:ESLint 和 Prettier 打架是常态,我的做法是在 ESLint 配置里直接关掉那些和格式化有关的规则,让 Prettier 全权负责排版,ESLint 只管代码逻辑质量。
最后我想提醒你,工具只是手段,别为了工程化而工程化。别把时间都花在折腾配置文件上,代码能跑、能维护、不出低级事故,这才是我们上这些工具的真实目的。如果你在配 v9 的时候发现 eslint.config.js 怎么写都不生效,试着删掉项目根目录下的 .eslintrc 旧文件试试,很多莫名其妙的问题都是这玩意儿在搞鬼。