给同一个 ESLint 配置按目录分层,我们用 overrides 把规则冲突压下去了
一个项目里只有一套 ESLint 规则,几乎必然会在某些目录里“误伤”或“漏掉”问题。最典型的场景:src/ 里是打包进应用的业务代码,scripts/、config/、test/、e2e/ 里是 Node 脚本、构建配置和测试代码。它们运行环境不同、依赖来源不同、对 console 和 any 的容忍度也不同。要让同一份配置按目录分层执行,overrides 是目前最直接、可维护性也最高的手段。
先给结论:把“默认规则”压到最严,再用 overrides 按目录逐层放开
很多人用 overrides 的方式是“哪边报错就单独给哪边加例外”,最后配置里堆满零散的 files: ['src/utils/xxx.ts'] 这种补丁。更稳的做法是反过来:在顶层 rules 里执行全项目最严格的基线,然后按目录类型逐层放宽。这样任何新增目录默认都会受到最严约束,只有你明确声明过的目录才会获得豁免——配置的行为更可预测,也不容易漏。
// .eslintrc.cjs
module.exports = {
root: true,
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
// 全局最严基线
'no-console': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'error'
},
overrides: [
{
// 测试目录:允许 any、允许显式返回类型省略
files: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off'
}
},
{
// Node 脚本与配置文件:允许 console
files: ['scripts/**/*.ts', 'config/**/*.ts', '*.config.ts'],
rules: {
'no-console': 'off'
}
}
]
}
这个配置里,业务代码 src/ 下写 console.log 会直接报 error,而 scripts/deploy.ts 里写 console.log 不会。同时 src/ 和 scripts/ 里所有函数都必须显式声明返回类型,但测试文件里可以省略——因为测试里大量 () => {} 回调写返回类型纯属噪音。
files 的匹配规则比你想的更容易踩坑:目录模式必须写 **
ESLint 的 overrides 用 minimatch 做 glob 匹配。一个常见错误是写 files: ['tests/'] 以为能匹配整个目录,实际上它匹配不到任何 .ts 文件。要匹配目录下所有文件,必须写 tests/**/*.ts 或 tests/**。同理,files: ['*.test.ts'] 只匹配根目录下的文件,不会匹配 src/components/Button.test.ts——要匹配任意层级,得写 **/*.test.ts。
另外要注意 overrides 的匹配是“文件路径相对于项目根目录”的 glob,并且对 .eslintignore 里排除的文件不会生效。如果某个目录在 .eslintignore 里,overrides 写得再精确也管不到它。
一个更实际的场景:src/ 里还要再分“应用代码”和“生成的代码”
有时候冲突不在 src/ 和 scripts/ 之间,而在 src/ 内部。比如你接了某个 codegen 工具,生成的类型文件、GraphQL 请求函数放在 src/generated/ 下。这些文件动辄几千行,经常出现 any、大量参数、无返回类型标注。如果按最严规则去 lint,要么生成完手动改,要么每次 lint 刷屏。
这时候 overrides 可以嵌套分层:
overrides: [
{
files: ['src/**/*.ts', 'src/**/*.tsx'],
rules: {
'@typescript-eslint/no-explicit-any': 'error'
}
},
{
files: ['src/generated/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': 'off'
}
}
]
这里注意一个 ESLint 的细节:多个 overrides 条目匹配同一个文件时,后面的条目会“合并”前面的规则配置,同一条规则以后出现的为准。所以上面两个条目同时匹配 src/generated/api.ts 时,第一个条目把 no-explicit-any 设为 error,第二个条目又把它关掉,最终生效的是第二个——因为它在数组里更靠后。这个“后者覆盖前者”的机制让分层配置可以像 CSS 一样“先定大范围,再覆盖小范围”。
overrides 不只改 rules,还能换 parser 和 parserOptions
分层配置最狠的用法是连解析器都换掉。比如一个项目里同时有 .js 和 .ts 文件,顶层 parser 如果设成 @typescript-eslint/parser,那 .js 文件也会被 TS parser 解析,虽然通常能工作,但某些规则行为会不一样。更干净的做法是:
module.exports = {
root: true,
extends: ['eslint:recommended'],
overrides: [
{
files: ['*.ts', '*.tsx'],
parser: '@typescript-eslint/parser',
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: __dirname
},
extends: ['plugin:@typescript-eslint/recommended'],
rules: {
'@typescript-eslint/no-floating-promises': 'error'
}
},
{
files: ['*.js', '*.mjs', '*.cjs'],
env: {
node: true,
es2022: true
},
rules: {
'no-console': 'off'
}
}
]
}
这样 .js 文件完全不会加载 TS 相关的 parser 和规则,tsconfig.json 也不会被 .js 文件引用。在 monorepo 或多包结构里,这个模式几乎是必须的——不同包可能有不同的 tsconfig.json,用 overrides 按包路径分别指定 parserOptions.project,可以避免“一个包引用到另一个包的 tsconfig”这种隐蔽错误。
实战中真正解决“规则冲突”的,是把 overrides 和“规则细分”配合起来用
光靠 off / error 两档切换,很多目录差异表达不出来。比如 no-console 在应用代码里要禁 console.log,但允许 console.warn 和 console.error;在脚本里则全放开。这时可以用规则的参数化配置:
rules: {
'no-console': ['error', { allow: ['warn', 'error'] }]
},
overrides: [
{
files: ['scripts/**/*.ts'],
rules: {
'no-console': 'off'
}
}
]
再比如 @typescript-eslint/no-unused-vars 在业务代码里要报 error,但在“只导出类型”的 .d.ts 文件里,函数参数不写名字是常见做法,这时候可以在 overrides 里用参数化配置只忽略参数:
overrides: [
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { args: 'none' }]
}
}
]
这种“同一条规则、不同参数”的分层,比简单 off 掉要精细得多,也不容易在放开目录里彻底失去保护。
配置落地的两个工程化建议:把 overrides 拆出去,别让 .eslintrc 膨胀到 500 行
当 overrides 超过 5 个条目,或者不同目录的规则差异开始互相打架时,单文件配置会变得很难 review。我的做法是拆成独立的配置片段,再在主配置里合并:
// eslint/configs/test-override.js
module.exports = {
files: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/explicit-function-return-type': 'off'
}
}
// eslint/configs/scripts-override.js
module.exports = {
files: ['scripts/**/*.ts', 'config/**/*.ts'],
rules: {
'no-console': 'off'
}
}
// .eslintrc.cjs
const testOverride = require('./eslint/configs/test-override')
const scriptsOverride = require('./eslint/configs/scripts-override')
module.exports = {
root: true,
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
'no-console': 'error',
'@typescript-eslint/no-explicit-any': 'error'
},
overrides: [testOverride, scriptsOverride]
}
每个 override 片段职责单一,review 时看 diff 就知道“这次改了测试目录的哪条规则”。如果项目里用 ESLint 9 的 flat config,这个思路更自然——flat config 本身就是数组,每个对象天然就是一个“分层块”,连 overrides 字段都不需要了。
最后一个提醒:改完 overrides 后,跑一次 npx eslint --print-config src/components/Button.tsx > /tmp/button-config.json,直接看某个具体文件最终生效的规则集。这比对着配置猜“这条规则到底被哪个 override 覆盖了”要快得多,也避免“我以为关了但其实没关”的乌龙。
常见问题
overrides 里匹配同一个文件的多个条目,规则冲突时谁生效?
数组里靠后的条目生效。ESLint 会按照 overrides 数组的顺序依次应用匹配的条目,同一条规则以后出现的配置为准。所以写配置时把“更宽泛的目录规则”放前面、“更具体的例外目录”放后面。
files 里写 src/** 和 src/**/*.ts 有什么区别?
src/** 会匹配 src/ 下所有层级的所有文件,包括 .json、.md 等非 JS/TS 文件;src/**/*.ts 只匹配 TypeScript 文件。如果 override 里要改 parser,用前者匹配到非 TS 文件会出问题,所以通常用后者。另外 src/**/*.ts 不会匹配 src/index.ts 吗?会,** 可以匹配零层目录。
为什么我在 overrides 里给某个目录关了规则,但 eslint --fix 还是把它改了?
--fix 只修复能被“安全修复”的规则。有些规则你关了,但别的 rule 或插件仍然会修它。更常见的是:你关的是 rules 里的一条,但同一条规则在 extends 引入的配置里又被打开了,而你的 overrides 条目写在数组前面,被后面的覆盖。用 --print-config 看最终生效值最靠谱。
flat config 里还需要 overrides 吗?
不需要。ESLint 9 的 flat config 本身就是数组,每个数组项可以带 files 字段来限定匹配的文件,天然就是分层结构。旧版 .eslintrc 的 overrides 迁移到 flat config 时,直接拆成多个带 files 的数组项即可。