告别 .eslintrc:ESLint v9 扁平化配置迁移实录与坑点

大前年底我们在升级一个基于 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.config.js import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; import reactHooks from 'eslint-plugin-react-hooks'; import reactRefresh from 'eslint-plugin-react-refresh'; import prettierConfig from 'eslint-config-prettier'; export default tseslint.config( // 基础 ESLint 推荐规则 eslint.configs.recommended, // TypeScript 推荐规则 ...tseslint.configs.recommended, // 自定义忽略项,替代 .eslintignore { ignores: ['dist', 'build', '*.config.js', 'coverage'], }, // 针对 TS/TSX 文件的特定规则 { files: ['**/*.{ts,tsx}'], languageOptions: { parser: tseslint.parser, parserOptions: { project: './tsconfig.json', ecmaVersion: 2022, sourceType: 'module', }, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': 'warn', // 关闭与 Prettier 冲突的规则,具体原因在第三章详述 '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], }, }, // 最后应用 Prettier 配置,确保覆盖所有格式化规则 prettierConfig, );

在迁移过程中,我注意到 ESLint v9 对 ignores 的处理逻辑变了。在旧版中,.eslintignore 里的 build 会忽略所有目录下的 build 文件夹。但在扁平化配置中,ignores 必须放在一个独立的配置对象中,且如果放在数组末尾,它会影响所有前面的配置。如果不这么做,你会发现 build 目录下的临时文件依然会被检查,导致大量的解析错误。

性能上的提升是立竿见影的。迁移前,全量检查 1200 个文件耗时约 8500ms;迁移后,得益于扁平化配置去除了复杂的目录遍历,耗时降到了 6200ms 左右。如果配合后面章节提到的 lint-staged,开发体验的提升会更加明显。

百万DAU社区实战:Husky + 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 进行了适配:

{ "scripts": { "prepare": "husky init", "lint": "eslint . --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0", "format": "prettier --write \"src/**/*.{ts,tsx,css,md}\"", "type-check": "tsc --noEmit" }, "lint-staged": { "*.{ts,tsx,js,jsx}": [ "eslint --fix", "prettier --write" ], "*.{css,md,json}": [ "prettier --write" ] } }

同时,.husky/pre-commit 文件的内容需要精简,直接调用 npx lint-staged

#!/usr/bin/env sh . "$(dirname -- "$0")/h" npx lint-staged

为了验证优化效果,我做过一次具体的数据对比。在一次涉及 15 个文件的特性分支提交中:

性能提升了 30 倍以上。原因在于 lint-staged 绕过了大量的文件 I/O 和解析开销。

这里有个细节需要注意。有一次线上接口突然变慢,排查下来发现是某个同事为了省事,在代码里加了大量的 eslint-disable 注释,导致 ESLint 规则失效,最终引入了一个逻辑死循环。因此,我在 lint-staged 的 ESLint 命令中加入了 --report-unused-disable-directives 参数,如果有人写了无用的禁用指令,提交时会被直接拦截。这比在 CI/CD 流水线中才报错要高效得多,毕竟在本地就能阻断“坏代码”进入仓库。

独家解析:如何解决 ESLint 与 Prettier 的格式化冲突

很多团队在引入工程化规范时,最头疼的问题不是配置工具,而是 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 专心负责代码美观。

具体的实施步骤在我们的订单系统中是这样操作的。首先安装依赖:

npm install -D eslint-config-prettier eslint-plugin-prettier

关键在于配置文件的加载顺序。在 ESLint v9 的扁平化配置中,必须确保 eslint-config-prettier 是最后一个被应用的配置。我在第一章给出的 eslint.config.js 中已经体现了这一点:

// 引入 prettier 配置 import prettierConfig from 'eslint-config-prettier'; export default tseslint.config( // ... 其他配置 { files: ['**/*.{ts,tsx}'], rules: { // 这里可以保留 ESLint 的逻辑检查规则 'no-var': 'error', 'prefer-const': 'error', } }, // 核心:放在数组最后,覆盖掉所有与 Prettier 冲突的格式化规则 prettierConfig );

如果不这么做,比如你把 prettierConfig 放在了中间,那么后面加载的其他插件可能会重新开启某些格式化规则,导致冲突复现。

除了配置层面的解决,我还遇到过一个隐蔽的问题。有一次我们发现某个文件在提交时总是被 Prettier 修改,但 ESLint 却不报错。排查后发现,是因为该文件里有一行超长的字符串,ESLint 的 max-len 规则和 Prettier 的 printWidth 设置不一致。解决方案是直接在 ESLint 配置中移除 max-len 规则,完全交给 Prettier 的 printWidth: 100 来控制。

对于编辑器集成,我建议团队统一使用 editor.formatOnSave 配合 ESLint 插件。在 VSCode 的 settings.json 中,我的配置如下,确保保存时先执行 ESLint 修复逻辑问题,再由 Prettier 处理格式:

{ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "editor.formatOnSave": true, "prettier.requireConfig": true }

这种分工明确的策略,让我们在重构那个有 5 年历史的老项目时,新增代码的风格和质量得到了统一,且没有引入额外的工具链复杂度。

4. 从 TSLint 到 Oxc:前端 Linter 选型对比与未来趋势

去年我们团队在重构一个跑了 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 不会报错,因为它格式没问题 function processOrder(order) { var data = order.data; if (data) { // 这里 data 可能是 null,但 Prettier 不管 return data.items.map(i => i.price * i.quantity); } return 0; }

这段代码 Prettier 格式化完漂漂亮亮的,但 ESLint 结合 @typescript-eslint 能告诉你:var 不应该用,应该用 constdata 可能是 null,你得先处理;还有 order.data 如果不存在,items 就是 undefined。这就是两者的核心区别:Prettier 不关心你代码会不会崩,它只关心缩进是不是 2 个空格

不过,今年社区里吵得挺凶的是 OxcBiome。这两个都是用 Rust 写的,速度确实快得离谱。我上个月在一个 30 万行代码的 Monorepo 里试了下 Oxc 的 Linter 原型,解析速度比 ESLint 快了差不多 15 倍。但现实是,我们项目里依赖了大量的自定义 ESLint 插件,比如我们自研的那个检查 API 调用规范的插件,还有跟公司内部的代码规范平台对接的规则。Oxc 现在还没法支持这些自定义逻辑,所以目前我只会用它做本地快速检查,生产环境还是跑 ESLint。

关于 ESLint v9 的扁平化配置,我刚开始迁移的时候确实有点烦。以前我们习惯在子目录放 .eslintrc.js 继承根目录配置,现在 v9 强制用 eslint.config.js,整个配置是一个数组,直接扁平化了。我们那个 Monorepo 有 5 个子包,迁移过程大概花了半天。好处是现在配置查找快了,而且你一眼就能看清楚哪些规则在哪个层级生效。比如我们针对测试文件放宽了一些规则,现在是这样写的:

// eslint.config.js import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import prettierConfig from 'eslint-config-prettier'; export default [ js.configs.recommended, ...tseslint.configs.recommended, prettierConfig, { files: ['**/*.test.ts'], rules: { 'no-console': 'off', // 测试里允许 console '@typescript-eslint/no-explicit-any': 'off' // 测试 mock 经常要用 any } }, { ignores: ['dist/**', '*.config.js'] } ];

这个配置我跑了好几次才调对,特别是 ignores 的位置,v9 里必须放在数组最后或者单独一个对象,不然不生效。

至于未来趋势,我觉得 AI 辅助修复 这块会是个大变化。我们现在用 --fix 只能修格式和简单逻辑,但像“这个 useEffect 依赖项没写全”这种问题,AI 结合上下文其实能给出更精准的修复建议。另外,Monorepo 的增量检查也是刚需,我们现在的 pnpm 项目,每次提交只检查改动包及其依赖,这比全量跑快了 70% 左右(从平均 8 秒降到 2.4 秒)。

5. 线上事故复盘:一行未 Lint 的代码如何导致内存泄漏

这事得从今年 3 月份说起。我们那个负责实时推送订单状态的 Node.js 微服务,在大促期间突然开始 OOM(内存溢出),重启后大概 20 分钟又挂。当时 QPS 其实不算高,也就 800 左右,但内存占用从启动时的 120MB 一路飙到 1.4GB。

我上去排查,先看了 process.memoryUsage(),发现 heapUsed 一直在涨。用 node-memwatch 打了个快照,对比后发现有个 Map 对象一直在变大。顺着代码找过去,问题出在一个 WebSocket 连接管理的模块里。

那个模块是两年前写的,当时为了图省事,直接在全局挂了个连接池。代码大概长这样:

// connectionManager.ts const connections = new Map<string, WebSocket>(); export function addConnection(userId: string, ws: WebSocket) { connections.set(userId, ws); ws.on('message', (data) => { console.log(`Received from ${userId}:`, data); }); // 这里只处理了 error,没处理 close ws.on('error', () => { connections.delete(userId); }); } // 问题就在这里:如果用户正常关闭连接,这个 Map 里的引用根本删不掉

看到没?这个 connections 是个全局的 Map,只有在 error 事件时才删除。如果用户正常关闭连接(比如关掉浏览器),close 事件没监听,这个 Map 里的引用就永远留着。而且 WebSocket 对象本身还带着缓冲区,每个连接大概占 2-3MB 内存。那天大促,用户频繁刷新页面,连接不断新建,旧的又没释放,内存不爆才怪。

为什么 Lint 没拦住? 因为当时这个项目还没强制开启 @typescript-eslint/no-floating-promises 和自定义的生命周期检查规则。而且这种逻辑错误,Prettier 肯定不管,ESLint 默认规则也不管。我们后来加了一条自定义规则,专门检查这种全局缓存有没有对应的清理逻辑。

修复其实很简单,但当时线上压力太大,我们是用临时方案先止血的:

// 修复后的代码 const connections = new Map<string, WebSocket>(); export function addConnection(userId: string, ws: WebSocket) { connections.set(userId, ws); const cleanup = () => { connections.delete(userId); console.log(`Connection cleaned up for ${userId}, remaining: ${connections.size}`); }; ws.on('message', (data) => { console.log(`Received from ${userId}:`, data); }); ws.on('close', cleanup); // 关键:补上 close 事件 ws.on('error', cleanup); // error 也要清理 } // 另外,为了防止万一,加了个定时清理僵尸连接的任务 setInterval(() => { for (const [userId, ws] of connections) { if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) { connections.delete(userId); } } }, 60000);

改完上线,内存曲线立马平稳了。但这次事故让我意识到:代码能跑和代码安全是两回事。后来我在团队的 ESLint 配置里加了一条硬规则:所有全局变量、模块级缓存,必须有对应的清理机制,否则直接阻断提交。

我们用 lint-staged 配合 Husky v9.1.7 做了个门禁。现在每次 git commit,只会检查改动的文件,速度很快(平均 300ms 左右)。配置大概是这样:

// package.json { "lint-staged": { "*.{ts,tsx}": [ "eslint --fix", "prettier --write" ] } }
# 使用 husky init 初始化(Husky v9 的新命令) npx husky init # 然后在 .husky/pre-commit 里写: npx lint-staged

这次事故之后,我们还专门写了一个 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 旧文件试试,很多莫名其妙的问题都是这玩意儿在搞鬼。