
自覆盖忽略这个词是我在排查一次 ESLint 突然什么文件都不检查、CI 却绿灯通过的诡异问题时给这个问题起的名字。明明配置里写了覆盖规则也写了忽略规则ESLint 却像自己被自己绕晕了一样该管的文件没人管该放过的文件又静默吞掉整个工程 lint 形同虚设。这类问题在 legacy.eslintrc和新的eslint.config.jsFlat Config里表现形式完全不同但根子都在于“忽略”和“覆盖”两套机制的文件模式互相叠盖。这篇文章适合两类人看一类是把 ESLint 当黑盒用、遇到“规则没生效”只能清缓存重启的纯使用者另一类是维护工程规范、经常跟 monorepo 下的复杂overrides结构打交道的配置管理员。我会从机制原理讲到实际操作再给出一套我自己踩了多次坑之后沉淀下来的配置策略内容偏实战尽量让你看完就能排查出自己项目里的“自覆盖忽略”问题。1. 先弄清两个机制忽略与覆盖1.1 忽略文件的黑名单机制忽略机制在 ESLint 里的表现形态有好几种传统的.eslintignore文件、legacy 配置里的ignorePatterns字段、Flat Config 下的ignores属性以及命令行里的--ignore-pattern。它们本质都是同一件事给文件系统做一次黑名单过滤命中的文件直接不进 lint 队列。忽略的逻辑优先级非常高近似“一票否决”。一个文件只要被任意一条忽略规则命中后面无论写过什么覆盖规则、什么插件、什么解析器配置统统不会执行。这就好比小区门口的黑名单只要你的名字出现在门卫的名单上你就进不了小区别说你住哪栋楼连保安亭都过不去。很多人对忽略机制的误区在于以为“忽略”和“覆盖”是同级配置可以互相修正。实际上忽略是提前拦截覆盖是拦截之后的事情。ESLint 不会先给文件配上覆盖规则再检查它是否被忽略而是先确认文件有没有资格被 lint没资格就什么都不谈。这里还要注意一个容易被忽略的细节legacy 配置里ignorePatterns使用的是 gitignore 风格的 glob而overrides里的files使用的是 minimatch 风格的 glob。这两种模式看起来写起来差不多实际语义差别很大。比如dist在 gitignore 风格里会匹配任意层级的dist目录但在 minimatch 里只匹配根目录下的dist*.js在 gitignore 风格里可以匹配任意目录下的 js 文件在 minimatch 里只匹配当前目录层级。这个差异是很多“配置看着对但行为诡异”问题的起点。1.2 覆盖给不同文件发不同的规则卡覆盖机制对应 legacy 里的overrides字段和 Flat Config 里同时包含files的配置对象核心作用是对不同文件集合应用不同的规则集。典型场景是普通源码走严格的规则测试文件放宽no-unused-expressions配置文件单独关掉no-consolemonorepo 里不同 package 使用不同规则版本。overrides之所以强大是因为它按文件模式动态匹配同一份配置里的规则不再是全局统一的。你可以把它理解成一座大楼的分层管理整栋楼有统一的门禁全局规则但 10 楼可以额外规定不准养大型犬11 楼可以额外规定晚上十点后禁止洗澡。这就是覆盖的本意。但问题也随之而来覆盖规则里声明了files时ESLint 会根据模式筛选候选文件。如果这些候选文件同时撞进了忽略黑名单那覆盖规则就成了“空头支票”。反过来如果你在覆盖规则里写了ignorePatternslegacy或ignoresflat其作用范围限制在当前覆盖块内部很多人会误以为它能反向“复活”被全局忽略的文件于是矛盾就此滋生。1.3 自覆盖忽略的三种典型现场我总结下来“自覆盖忽略”的现象基本可以归为三类各位可以对号入座第一种是模式重叠。全局忽略列表里已经写了src/generated/**覆盖规则里又给src/generated/**单独配置了no-undef关闭项。表面上看你既忽略了这些文件又给它们开了特例实际上忽略优先级高于覆盖覆盖规则永远没机会执行。你在配置里写了一句“我需要管它”又在另一处写了一句“我不管它”ESLint 最终选择了后者这就是配置层面的自我矛盾。第二种是取反模式串层。你想忽略某个目录又想放行其中个别文件于是在.eslintignore里写了dist/**在overrides[].ignorePatterns里写了!dist/keep.js。你本意是希望覆盖规则里的取反能抵消全局忽略但 ESLint 处理忽略时先处理全局列表文件在第一步就被判了死刑后面的覆盖块根本运行不到。这就好比你在大门前被保安拦下你在 10 楼前台说“我是业主”也没用因为根本进不了电梯。第三种是 Flat Config 下的交错顺序问题。在eslint.config.js里先给所有 TS 文件开了规则随后一个ignores把某目录拉进黑名单之后又有一个files试图给该目录单独配规则。数组顺序和 ignore 累积的逻辑叠加在一起导致文件永久性从 lint 队列消失而你只是把 ignore 写在了规则对象的前面或后面就成了完全不同的行为。2. legacy 配置里的经典冲突overrides 与 ignorePatterns 的相爱相杀2.1 从 .eslintignore 到 ignorePatterns取反模式是怎么工作的在切换到 Flat Config 之前绝大多数项目用的是.eslintrc.eslintignore的组合。.eslintignore支持 gitignore 风格的语法其中!开头的行表示取反。比如dist/ !dist/keep.js这个规则列表的语义是dist 目录下所有文件都被忽略但dist/keep.js除外。这个取反能够成立的前提是正反模式位于同一个忽略列表里ESLint 会按顺序处理整份列表允许后续的取反抵消先前的忽略。ignorePatterns字段的作用和.eslintignore类似但它可以直接写在.eslintrc里。顶层ignorePatterns自然成为全局忽略overrides块里的ignorePatterns则只对当前覆盖块的候选文件做二次过滤。这里的关键点在于ESLint 评估“文件是否被忽略”时全局忽略和覆盖块的本地忽略不是同一次遍历。全局列表先把文件从候选池里捞走覆盖块连看都看不到它更别提覆盖块里的取反模式。跨列表的取反在 legacy 模式下基本是无效操作。2.2 现场复现为什么覆盖配置让忽略一夜失效我来做一个可以直接复现的实验。假设项目里有这样的文件结构src/ components/ button.test.js fixtures/ button.test.js配置文件长这样// .eslintrc.cjs module.exports { ignorePatterns: [**/fixtures/**], overrides: [ { files: [**/*.test.js], ignorePatterns: [!**/fixtures/**], env: { node: true }, rules: { no-unused-expressions: off }, }, ], };我们的预期可能是全局忽略所有 fixtures 下的文件但覆盖块里对 test.js 文件做特殊规则并且通过!**/fixtures/**放行 fixtures 下的测试文件。然而实际执行时npx eslint src/components/button.test.js src/fixtures/button.test.js输出结果只有src/components/button.test.js被 lintsrc/fixtures/button.test.js直接没有任何输出。原因很简单ESLint 先执行了顶层ignorePatterns**/fixtures/**已经匹配到了src/fixtures/button.test.js这个文件在遍历阶段就被标记为 ignored。覆盖块里的取反模式根本没机会参与第二次判定。如果你把顶层ignorePatterns去掉只在覆盖块的ignorePatterns里写[**/fixtures/**, !src/fixtures/button.test.js]那这个文件反而能正常工作。也就是说取反必须和正模式放在同一个列表里跨列表取反是典型的无效配置。2.3 手动计算生效链ESLint 如何判定一个文件是否进入队列我在 debug 这类问题时手动整理了一套判定链每次都能快速定位问题。可以把 ESLint 对单个文件的判定拆成下面几步收集全局忽略源.eslintignore文件、顶层ignorePatterns、命令行--ignore-pattern。对目标文件跑全局忽略匹配命中则标记为 ignored处理结束。若未被全局忽略遍历所有overrides判断files模式是否匹配当前文件。对匹配的覆盖块再检查其内部ignorePatterns。覆盖块内部的忽略发生在“已确认候选”之后。未被任何忽略命中的文件应用所有匹配到的规则此时配置顺序才决定规则覆盖关系。这套链的关键在于第 2 步和第 4 步是两套独立流程。全局忽略一旦命中就提前终止覆盖块内部的取反、排除、规则统统轮不到。所以凡是遇到“我在覆盖块里写了规则但文件没被 lint”第一步就该检查这个文件是否已经被全局忽略列表或.eslintignore拦截了。有一回我看到一个项目里开发者在.eslintignore里写死了所有.d.ts文件又在overrides里给vite-env.d.ts单独配了no-undef关闭项意图是让这个特殊声明文件能过检查。结果自然是规则完全不生效因为.eslintignore的优先级太高了。与其纠结取反不如直接在全局忽略列表里用取反语法把想保留的文件先救出来或者干脆别忽略.d.ts改用入口过滤的方式处理。3. flat config 下的新坑overrides 里的 ignores 会把全局 ignore 顶掉3.1 flat config 的思维转变一切皆对象ignores 不是普通配置ESLint 8.21 之后开始推广 Flat Config到 9.x 成为默认.eslintrc模式退居二线。Flat Config 把整个配置拆成一个数组数组里的每个元素是一个配置对象对象通过files选择文件集合通过rules设置规则通过ignores排除文件。这套设计和 legacy 相比表面上是结构简化了实则暗含一套更严格的“顺序累积”逻辑配置数组从前到后依次执行ignores设置的不是一次性过滤而是往一个全局忽略模式池里追加内容。数组后续的配置对象在匹配文件时这个全局忽略池就已经生效了。很多人从 legacy 迁移到 flat config 时习惯性地认为ignores和旧版的ignorePatterns一样是局部过滤结果把忽略配置丢在了中间某个对象里导致前面对象配好的规则全部失效。这里要记住一个根本区别在 flat config 中如果一个配置对象只有ignores而没有files它修改的就是全局忽略状态影响范围是“从这一刻起所有后续匹配”。3.2 复现代码全局 ignores 生效加了覆盖就失效直接看一个典型事故现场。我有一次给某项目做规则收敛目标是所有 TS 文件关闭no-consolesrc/generated 目录下的生成代码关闭no-undef。看起来没什么问题配置文件结构如下// eslint.config.js export default [ { files: [**/*.ts], rules: { no-console: off, }, }, { ignores: [src/generated/**], }, { files: [src/generated/**], rules: { no-undef: off, }, }, ];文件树src/ generated/ schema.ts components/ Button.ts当我执行npx eslint src结果是什么样的src/components/Button.ts正常拿到no-console关闭配置没有任何报错。但src/generated/schema.ts完全不在处理范围内既没有no-undef豁免也没有no-console关闭。因为第二个配置对象把src/generated/**加进了全局忽略池第三个配置对象里的files: [src/generated/**]就像一个对着关闭的大门的请帖写了地址但送不到。更加隐蔽的变体是如果你把这第二个ignores对象放到数组末尾前面的files: [**/*.ts]规则对象仍然会匹配到src/generated/schema.ts但它同样不会进 lint 队列因为文件已经被全局忽略。也就是说ignores对象无论放哪里只要文件命中了黑名单所有files匹配都是空谈。3.3 官方对 ignores 的定义最后一个匹配生效机制官方文档里对ignores的说明有一条很容易被忽略flat config 中的 ignore 模式也是支持!取反的而且取反生效的规则是“同一个配置对象内按顺序处理”。比如{ ignores: [dist/**, !dist/keep.js], }这样dist目录下所有文件仍会被忽略但dist/keep.js会被保留下来。这个写法和 legacy 的.eslintignore取反类似是推荐的用法。问题是很多人试图跨配置对象取反[ { ignores: [dist/**] }, { ignores: [!dist/keep.js] }, ]在不同版本下这种行为并不完全一致。某些版本下后一个对象的取反可以抵消前一个对象的影响某些版本下却不行因为对象处理时用了不同的阶段判断。ESLint 官方对 flat config 的过滤模型强调的是“最终匹配状态”但在具体版本实现里跨对象取反并不总那么丝滑所以我的建议非常明确正模式和取反模式务必放在同一个ignores数组里不要分散在不同配置对象中。另外还要注意一点在 flat config 中--no-ignore这个 CLI 参数不再可用。过去你在 legacy 下可以通过--no-ignore强行 lint 被忽略的文件来验证但 flat config 下此路不通。想验证一个文件是不是被忽略了只能用 debug 模式或--print-config加调试输出没有捷径。4. 自查与排查三步定位你被冤枉的文件4.1 第一步把 ESLint 的“决策过程”打到控制台排查自覆盖忽略问题最忌讳反复改配置重启 lint那样只能靠猜。正确做法是直接让 ESLint 把内部决策过程打出来。legacy 模式下用DEBUGeslint:config-array-factory npx eslint src/fixtures/button.test.js 21 | grep button.testflat config 模式下用DEBUGeslint:config-array-factory DEBUGeslint:flat-config-array npx eslint src/generated/schema.ts 21 | grep schemadebug 输出里会显示文件匹配了哪条 glob、是否进入 ignore 列表、匹配到了哪些配置对象。重点看文件名的处理日志如果日志里出现了 “ignored” 标记或者匹配配置对象数量为 0那基本可以断定是被忽略机制拦截了。我在实际操作中发现这种方式比任何配置文件分析工具都直观因为 debug 日志反映的是 ESLint 运行时真实的判定顺序而不是我们脑补的逻辑顺序。遇到“规则没生效”时先打 debug 日志能过滤掉一半以上的假问题。4.2 第二步对照配置文件写“决策表”如果不想被 debug 日志刷屏还有更朴素的办法手工整理一张决策表。把目标文件列出来再把配置文件里所有影响该文件的规则对象、忽略模式、覆盖块逐一列出按执行顺序从上往下写。举个例子排查src/fixtures/button.test.js的决策表可以这样执行顺序配置来源模式内容是否命中结论1顶层 ignorePatterns**/fixtures/**是文件进入忽略名单2overrides[0].files**/*.test.js未评估已被忽略无意义3overrides[0].ignorePatterns!**/fixtures/**未评估已被忽略取反无效这张表一写出来问题根源马上就浮出水面。很多人不写表的时候觉得配置没问题一写表就会发现自己想要的“覆盖”在逻辑顺序上根本轮不到执行。4.3 第三步用等价正则在 Node 里模拟匹配如果项目里 glob 模式特别多写决策表会有点累。这时候可以借助minimatch包在 Node 里快速验证模式之间的包含关系。操作步骤安装 minimatch 后写一个极简脚本模拟 ESLint 的忽略流程const { minimatch } require(minimatch); const file src/generated/schema.ts; const globalIgnorePatterns [src/generated/**]; const overrideFiles [src/generated/**]; const isGloballyIgnored globalIgnorePatterns.some((pattern) minimatch(file, pattern) ); if (isGloballyIgnored) { console.log(文件被全局忽略覆盖规则不会生效); } else { const matchedByOverride overrideFiles.some((pattern) minimatch(file, pattern) ); console.log(文件是否命中覆盖规则, matchedByOverride); }这样跑一下你立刻可以验证“全局忽略优先”的判断是否成立。虽然这是对 ESLint 内部机制的一个简化模拟但对于定位“哪个模式击沉了这个文件”已经足够。5. 避坑指南一套能落地的“忽略不覆盖”配置策略5.1 legacy 配置的稳妥写法如果你还在维护.eslintrc项目我的核心建议只有一条忽略规则全部收敛到.eslintignoreoverrides里不要写ignorePatterns更不能在覆盖块里试图用取反来“复活”全局忽略的文件。如果确实需要在覆盖块内“减掉”一部分文件正确做法是用files的取反模式。ESLint 的overrides.files支持 negated glob你可以这样写module.exports { overrides: [ { files: [**/*.test.js, !**/ui/fixtures/**], env: { node: true }, rules: { no-unused-expressions: off, }, }, ], };这里的!**/ui/fixtures/**表示测试文件这个集合里排除 ui/fixtures 下的子集。这种写法不碰全局忽略逻辑只在覆盖匹配阶段做减法行为可靠得多。再有就是统一 glob 风格的问题。.eslintignore里用 gitignore 风格overrides.files里用 minimatch 风格两处模式的写法不要盲复制。比如在.eslintignore里写dist会匹配任意层级的 dist 目录但在overrides.files里写dist只会匹配根目录下的 dist 文件。如果两套风格混用很容易出现“目录明明在前一份配置里被忽略得很好复制到 files 里后完全匹配不上”的尴尬。5.2 flat config 的推荐结构flat config 下我的推荐结构是全局忽略声明放在数组首部而且只做一个事情——维护忽略模式池。所有带files的规则对象一律不要混入ignores。标准结构可以长这样// eslint.config.js export default [ { ignores: [ node_modules/**, dist/**, coverage/**, src/generated/**, !src/generated/keep.ts, ], }, { files: [**/*.ts], rules: { no-console: off, }, }, { files: [**/*.test.ts, !**/ui/fixtures/**], rules: { no-unused-expressions: off, }, }, ];这里有一个细节!src/generated/keep.ts和src/generated/**放在同一个 ignores 数组里这样keep.ts会从忽略列表中放行后续配置对象仍然可以给它分配规则。同理测试文件想排除特定子集用files的取反模式实现比在规则对象的ignores字段里做减法要清晰得多。5.3 一条必须刻在心里的核心原则我把这条原则写在团队 eslint 规范的 README 第一行忽略模式永远不要和文件选择模式共享同一套字面量。什么意思如果你想“忽略 src/generated 下的绝大多数文件但给个别文件开规则”不要既在ignores里写src/generated/**又在files里写src/generated/**。两处模式一旦重叠忽略对象必然提前拦截覆盖对象必然成为摆设。真正可维护的做法是把忽略和覆盖的目标范围错开。要么全局范围不写死保留特例文件的放行口子要么干脆把生成目录从 lint 入口里排除比如 npm script 里直接写成eslint src --ext .js,.ts --ignore-pattern src/generated/**脚本层入口收窄配置文件里不加任何对应模式这样就不会有两套机制互相冲突的问题。5.4 场景化速查表我把实际项目里最常见的几个场景整理成表方便直接对着抄场景legacy 写法flat config 写法注意事项全工程忽略一个目录但保留其中个别文件.eslintignore写dist/和!dist/keep.jsoverrides不写忽略ignores数组里写[dist/**, !dist/keep.js]取反必须与正模式在同一个列表里覆盖块想对某类文件开规则同时排除子集files: [**/*.test.js, !**/ui/fixtures/**]files: [**/*.test.ts, !**/ui/fixtures/**]用files的 negated 模式实现减法给 generated 目录里某个文件单独关规则其余仍忽略不推荐容易混乱忽略数组里放行该文件后面用files匹配单独配置要么入口排除要么显式放行验证某个文件为什么没被 lint用DEBUGeslint:config-array-factory启动用DEBUGeslint:flat-config-array启动flat config 下--no-ignore不可用最后分享一个小技巧如果你维护的是大型 monorepo建议把 ignore 模式抽成一个独立模块统一导出比如eslint-ignore-patterns.jslegacy 和 flat config 都引用同一份常量。这样即使配置结构变了忽略列表也不会出现两处手写不一致的情况。毕竟大多数“自覆盖忽略”问题本质都是我们自己在不同位置写了互相冲突的规则ESLint 只是忠实执行了其中优先级更高的一条。