给同一个 ESLint 配置按目录分层,我们用 overrides 把规则冲突压下去了

一个项目里只有一套 ESLint 规则,几乎必然会在某些目录里“误伤”或“漏掉”问题。最典型的场景:src/ 里是打包进应用的业务代码,scripts/config/test/e2e/ 里是 Node 脚本、构建配置和测试代码。它们运行环境不同、依赖来源不同、对 consoleany 的容忍度也不同。要让同一份配置按目录分层执行,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 的 overridesminimatch 做 glob 匹配。一个常见错误是写 files: ['tests/'] 以为能匹配整个目录,实际上它匹配不到任何 .ts 文件。要匹配目录下所有文件,必须写 tests/**/*.tstests/**。同理,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,还能换 parserparserOptions

分层配置最狠的用法是连解析器都换掉。比如一个项目里同时有 .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.warnconsole.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 字段来限定匹配的文件,天然就是分层结构。旧版 .eslintrcoverrides 迁移到 flat config 时,直接拆成多个带 files 的数组项即可。