工程2026年10月

ESLint 常用配置:files、ignores、plugins、languageOptions 与 rules

打开一个用 Vite 生成的 eslint.config.js,或者翻看任何一份团队共享的 ESLint 配置,你会发现配置项来来回回就是那么几个:files、ignores、plugins、languageOptions、rules。但真正动手改配置时,问题马上就来了:

  • 新加了一个 src/utils.ts,为什么 ESLint 根本不检查它?
  • dist/ 明明写了忽略,为什么构建产物里的报错还是冒出来了?
  • 测试文件里的 describe、it 为什么被报成"未定义的变量"?
  • 装了 typescript-eslint,规则名前面的 @typescript-eslint/ 前缀是从哪来的?

这些问题指向同一件事:ESLint 的扁平配置(flat config)本质上是在回答"对哪些文件、用什么语言环境、加载哪些插件、执行哪些规则"。本文就以 files、ignores、plugins、languageOptions、rules 这五个最常用的配置项为主线,逐项讲清楚它们的行为和坑,顺带梳理插件生态里常见的第三方规范(Airbnb、Standard、Prettier、Stylistic、各框架插件),最后用一个可以从零复现的 demo 把所有配置跑一遍,验证每条结论。

本文基于 ESLint v10 撰写。自 ESLint v9 起扁平配置成为默认格式,v10 则彻底移除了旧的 eslintrc 体系(.eslintrc.* 和 .eslintignore 不再生效),eslint.config.js 是唯一的选择。如果你在老项目里见过 extends: "airbnb" 这样的写法,那已经是上一个时代的事情了。


1. 先理解扁平配置的基本模型

老版 eslintrc 是一份"带继承关系的 JSON",而扁平配置直白得多:配置文件导出一个数组,数组里每个对象都是一份"针对某些文件的完整检查方案"。

// eslint.config.js
import { defineConfig } from 'eslint/config'

export default defineConfig([
  {
    files: ['**/*.js'],            // 这份方案作用于哪些文件
    languageOptions: { /* ... */ }, // 这些文件按什么语言环境解析
    plugins: { /* ... */ },         // 加载哪些插件
    rules: { /* ... */ },           // 执行哪些规则
  },
])

defineConfig() 是从 eslint/config 导入的配置辅助函数。它不仅提供类型提示,还会自动拍平传入的对象和嵌套数组,因此组合不同形态的共享配置时,不必再反复判断是否需要 ...。不用它、直接导出扁平数组仍然合法,但此时数组中不能再嵌套数组。

一个配置对象可以包含的常用属性如下:

属性作用
name给配置对象起名,用于报错信息和配置检查器中定位来源
filesglob 数组,声明这份配置作用于哪些文件
ignoresglob 数组,声明排除哪些文件
extends继承插件预设、共享配置包或其他配置对象
languageOptions语言层面的设置:语法版本、模块类型、全局变量、解析器
plugins注册插件,建立"命名空间 → 插件对象"的映射
rules规则配置,决定每条规则的严重级别和参数
settings给所有规则共享的自定义数据(较少使用)

1.1 同一个文件会"穿过"所有匹配的配置对象

扁平配置最容易被误解的一点:数组里的配置对象不是互斥的,而是叠加的。一个文件会依次匹配所有声称覆盖它的配置对象,后者覆盖前者的同名属性,最终合并出这个文件的"最终配置"。

// eslint.config.js
export default defineConfig([
  {
    files: ['**/*.js'],
    rules: {
      semi: 'error',
      'no-console': 'error',
    },
  },
  {
    files: ['tests/**/*.js'],
    rules: {
      'no-console': 'off',   // 只覆盖这一条,semi 仍然生效
    },
  },
])

对于 tests/foo.test.js,两个配置对象都匹配,合并结果是:semi: 'error' + no-console: 'off'。合并过程可以用下图理解:

flowchart LR
    A["tests/foo.test.js"] --> B{"匹配配置对象 1<br/>files: **/*.js"}
    B -- "命中" --> C["semi: error<br/>no-console: error"]
    A --> D{"匹配配置对象 2<br/>files: tests/**/*.js"}
    D -- "命中" --> E["no-console: off"]
    C --> F["最终配置(后者覆盖前者)"]
    E --> F
    F --> G["semi: error<br/>no-console: off"]

所以写配置时的基本思路是:前面的对象定通用底线,后面的对象做局部修正。顺序很重要,数组末尾的对象拥有最终话语权。

1.2 不写 files 的配置对象作用于"所有被检查的文件"

如果配置对象既不写 files 也不写 ignores,它会作用于所有被 ESLint 检查的文件——也就是所有被其他配置对象"认领"的文件。所以全局通用的规则(比如所有文件都要的 eqeqeq)可以不写 files。

需要注意,ESLint 默认只检查 .js、.cjs、.mjs 三种扩展名的文件。.ts、.vue 这类文件必须被某个配置对象的 files 显式认领,否则 ESLint 会完全跳过它们,这一点在第 2 节会详细验证。

1.3 v10 的配置文件查找:从被检查文件所在目录向上找

顺带一个对 monorepo 友好的行为:ESLint v10 确定一个文件用哪份配置时,是从该文件所在目录开始,逐级向上查找eslint.config.*,而不是只看命令行的工作目录。这意味着 monorepo 里每个子包都可以放自己的 eslint.config.js,一次 eslint . 就能各用各的配置——这正是"ESLint 配置抽包"能够成立的基础。


2. files —— 配置应用于哪些文件

files 是一个 glob 模式数组,回答"这份配置管哪些文件"。模式相对于配置文件所在目录解析。

{
  files: ['src/**/*.js'],   // 只管 src 目录下的 JS 文件
  rules: { semi: 'error' },
}

2.1 非默认扩展名必须显式认领

前面说过,ESLint 默认只认 .js、.cjs、.mjs。项目里的 .ts 文件如果没有任何配置对象认领,执行 npx eslint src/helper.ts 时会看到这样的输出(本文第 7 节的 demo 中真实复现):

/Users/jie/.../eslint-demo/src/helper.ts
  0:0  warning  File ignored because no matching configuration was supplied

✖ 1 problem (0 errors, 1 warning)

"没有匹配的配置"不是说文件被忽略了,而是说没有任何配置对象愿意管这个文件,ESLint 不知道该用什么规则检查它,只能跳过。TypeScript 项目最常见的配置写法就是:

{
  files: ['**/*.{ts,tsx}'],   // 大括号展开:同时匹配 .ts 和 .tsx
  // ...
}

顺带一提,.vue 文件同理,需要靠 eslint-plugin-vue 的预设配置里的 files: ['**/*.vue'] 来认领。

2.2 常用的 glob 写法

模式含义
**/*.js任意目录下的 .js 文件
src/**/*.tssrc 目录(含子目录)下的 .ts 文件
**/*.{js,ts}大括号展开,同时匹配多种扩展名
**/!(*.*)匹配没有扩展名的文件(如某些脚本)
["src/*", "**/*.js"]数组套数组表示"与":既在 src 直接子级、又是 .js

最后一种"与"写法比较冷门,但在动态拼接 files 时比手工拼 glob 字符串安全。

2.3 一个容易踩的坑:*、/*、/** 结尾的模式无法扩大检查范围

官方文档对 files 有一条限制:模式不能是裸的 *,也不能以 /* 或 /** 结尾。这类"匹配一切"的模式不会让额外的文件进入检查范围——ESLint 只检查默认扩展名,或者被"带扩展名的具体模式"(如 **/*.ts)认领的文件。想让 .ts 文件被检查,写 **/*.ts 是有效的,写 src/** 则不会起到你期望的"把 src 下所有文件都 lint 一遍"的效果。


3. ignores —— 忽略文件

ignores 回答"哪些文件不要检查"。它有两种截然不同的身份,取决于配置对象里还有没有别的属性:

  • 全局忽略:配置对象里只有 ignores(可以再加个 name)。这些模式对所有配置对象生效,相当于整个项目的"免检查名单"。
  • 局部忽略:配置对象里除了 ignores 还有 rules 等其他属性。此时 ignores 只对这一个配置对象生效,意思是"这份配置不管这些文件",但别的配置对象仍可能检查它们。

实践中几乎总是想要全局忽略——dist/、coverage/ 这类产物目录,任何规则都不该去碰。官方推荐用 globalIgnores() 辅助函数把意图写明:

import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
  globalIgnores(['dist/', 'coverage/']),   // 等价于 { ignores: [...] } 单独成对象
  {
    files: ['**/*.js'],
    rules: { /* ... */ },
  },
])

3.1 全局忽略与局部忽略的写法差异

两种忽略的匹配能力不一样,这也是最容易出错的地方:

全局忽略局部忽略
生效范围所有配置对象仅当前配置对象
dist/(目录写法)✅ 可以,整个目录被忽略❌ 无效,只能匹配文件
dist/**✅ 可以✅ 可以
src/a.js(具体文件)✅ 可以✅ 可以

也就是说:忽略整个目录时,全局忽略写 dist/,局部忽略必须写 dist/**。在局部忽略里写 dist/ 什么都不会忽略,是静默失效的典型坑。

3.2 默认忽略与点开头文件

即使你什么都不写,ESLint 也默认忽略 **/node_modules/ 和 .git/。自定义的 ignores 是追加在默认规则之后的,不会把 node_modules 又放出来。

另一个反直觉的行为:点开头的文件和目录(如 .fixtures/、.foo.js)默认是参与检查的,只有 .git 被默认忽略。如果你的项目里有 .generated/ 这类目录,需要显式忽略它。

3.3 用 ! 把个别文件"捞回来"

ignores 支持 ! 取反,可以在一大片忽略中保留个别例外:

globalIgnores([
  '**/*.config.js',      // 忽略各种工具的配置文件
  '!**/eslint.config.js', // 但 ESLint 自己的配置文件仍然检查
])

典型场景:工具配置文件(vitest.config.js、postcss.config.js……)大多不值得 lint,但 eslint.config.js 本身是团队规范的一部分,值得被检查。


4. plugins —— 注册插件

ESLint 核心只内置了针对标准 JavaScript 的规则。要检查 TypeScript 语法、React Hooks 用法、Vue 模板,或者执行 Airbnb、Standard 这类团队规范,都得靠插件。

插件就是一个"规则集合包"。plugins 配置项的作用是把插件对象注册到一个命名空间下,之后规则就可以用 命名空间/规则名 的形式引用:

import tseslint from '@typescript-eslint/eslint-plugin'

export default defineConfig([
  {
    files: ['**/*.ts'],
    plugins: {
      '@typescript-eslint': tseslint,   // 命名空间 → 插件对象
    },
    rules: {
      '@typescript-eslint/no-explicit-any': 'error',  // 前缀就是命名空间
    },
  },
])

为什么规则名要带前缀?

因为不同插件可能提供同名规则(核心有 no-unused-vars,@typescript-eslint 也有增强版 no-unused-vars),命名空间保证了规则 ID 全局唯一。内置规则没有前缀,插件规则一律是 插件命名空间/规则名。

实际项目中很少有人逐条开插件规则,更常见的做法是继承插件自带的预设配置。每个插件通常都导出一组预设,比如 recommended(推荐)、strict(严格):

import tseslint from 'typescript-eslint'   // 元包,聚合了插件 + 解析器 + 预设

export default defineConfig([
  {
    files: ['**/*.{ts,tsx}'],
    extends: [tseslint.configs.recommended],  // 一条规则都不用写
  },
])

extends 会把预设里的 plugins、languageOptions、rules 整体继承过来,自己的配置对象只需要补充差异部分。这也是 Vite、Vue CLI 等脚手架生成的配置文件的标准形态。

4.1 官方与语言类插件:从 @eslint/js 到 typescript-eslint

包提供什么
@eslint/js官方内置规则的预设:js.configs.recommended(推荐,防错为主)和 js.configs.all(全开,仅适合研究)
typescript-eslintTypeScript 解析器 + TS 专属规则 + 预设(recommended、strict、stylistic 等系列)

js.configs.recommended 几乎是所有配置数组的第一块积木——它启用的都是 no-unused-vars、no-undef、no-redeclare 这类"帮你抓 bug"的规则,不涉及任何代码风格。

typescript-eslint 则是 TS 项目的标配。它做了两件事:一是用 languageOptions.parser 把默认解析器 espree 换成能看懂 TS 语法的解析器;二是提供一批 TS 专属规则,比如 @typescript-eslint/no-explicit-any(禁止 any)。预设按严格程度分层,常见三档:

  • tseslint.configs.recommended:基础推荐,不依赖类型信息,速度快;
  • tseslint.configs.recommendedTypeChecked:需要类型信息的增强检查(如"浮空 Promise"检测),要求配置 parserOptions.projectService 或 project;
  • tseslint.configs.strict:更严格的主观规则集合。

4.2 预设是对象还是数组:什么时候需要展开

刚开始抄配置时,很多人都会看到两种写法:

export default defineConfig([
  js.configs.recommended,          // 直接放,没有 ...
  tseslint.configs.recommended,    // 数组预设,defineConfig 会自动拍平
  eslintConfigPrettier,            // 又是直接放
])

同样是预设,为什么有的教程仍会写 ...tseslint.configs.recommended?

关键在于两点:配置数组最终必须是一维的;defineConfig() 会自动拍平嵌套数组。而各个预设的“真实形态”又不相同:

  • js.configs.recommended 是一个配置对象(形如 { name: 'js/recommended', rules: {...} }),直接放进数组即可;
  • tseslint.configs.recommended 是一个数组,里面装着多个配置对象;使用 defineConfig() 时可以直接传入,也可以用 ... 手动展开;
  • eslint-config-prettier 也是单个对象,所以同样直接放。

为什么 typescript-eslint 一个预设要拆成三块?把这个数组的内容打印出来就明白了(实际输出):

[0] typescript-eslint/base               → 键:name, languageOptions, plugins
[1] typescript-eslint/eslint-recommended → 键:name, files, rules
[2] typescript-eslint/recommended        → 键:name, rules

三块各司其职:

  • base:把解析器换成 @typescript-eslint/parser(第 5.3 节说的"换 parser"就是它在幕后做的),并把插件注册到 @typescript-eslint 命名空间;
  • eslint-recommended:关掉一批与 TypeScript 重复或冲突的核心规则,比如 no-undef——类型检查器查"未定义变量"比 ESLint 准得多,核心规则再查一遍只会误报;
  • recommended:真正的 TS 推荐规则本体,典型做法是关掉某条核心规则、换成对应的 TS 增强版,例如 no-array-constructor → @typescript-eslint/no-array-constructor。

这本身就是扁平配置"数组叠加"思想的内部实践:一个预设包,自己就是一小组分好层的配置对象。

是否需要展开,取决于导出方式:

  • 裸导出数组(不写 defineConfig()):必须用 ... 展开数组预设,否则会形成嵌套数组并导致配置加载失败;
  • 使用 defineConfig():可以直接传入对象或数组预设,它会递归拍平;保留 ... 也没有问题,但不是必需的。

因此,不要把“某个预设必须展开”记成固定规则。先看项目是否使用 defineConfig(),再看预设导出的是对象还是数组。

怎么判断一个陌生预设是对象还是数组?

两个办法:

  1. 一看插件文档,优先照当前版本的官方示例组合;
  2. 二在已安装的项目里跑一行直接验证:
node -e "import('typescript-eslint').then(m => console.log(Array.isArray(m.default.configs.recommended)))"
# 输出 true → 这是数组预设;裸数组导出时需要 ...,defineConfig() 会自动拍平

经验规律:@eslint/js、eslint-config-prettier 的预设是单对象;typescript-eslint 的 recommended、strict 等组合预设是数组(个别单功能预设如 disableTypeChecked 反而是单对象)。其他插件两种形态都有——别猜,动手查一下。

最后补一个作用域上的细节:上面例子里 js.configs.recommended 摆在数组顶层、不带 files,它会作用于所有被检查的文件;而第 4.1 节的写法是把它放进某个带 files 的配置对象的 extends 里,作用域就被收窄到那一类文件。两种摆法都合法,区别只在作用范围。

4.3 代码风格规范:Airbnb、Standard 与它们的后代

"第三方规范"最经典的就是 Airbnb 和 Standard 这两套开箱即用的规则集。但截至本文撰写时,它们在扁平配置时代的处境值得注意:

  • Airbnb(eslint-config-airbnb / eslint-config-airbnb-base):经典规则集当前仍以 eslintrc 形式发布,没有原生 flat config 入口。ESLint v9 可借助 FlatCompat 转换旧配置,但 ESLint v10 已移除 eslintrc 配置体系;新项目更适合选择原生支持 flat config 的规则集,或者自行维护迁移后的配置。
  • Standard(eslint-config-standard / standard):原项目仍可使用,但当前公开用法仍围绕自身 CLI 和旧式配置。希望直接接入 ESLint flat config 时,可以选择自称 StandardJS“精神继任者”的 neostandard,它提供原生扁平配置:
import neostandard from 'neostandard'

export default neostandard({
  // 按需传入 env、files、ts 等选项
})
  • Stylistic(@stylistic/eslint-plugin):ESLint 官方从 v8.53 起把所有"格式化类"规则(缩进、引号、空格等)标记为废弃,计划在 v11 移除,官方建议这类需求交给 Prettier 或社区插件。这些规则被原维护者迁移到了 @stylistic/eslint-plugin,想继续用 ESLint 管格式的项目可以用它:
import stylistic from '@stylistic/eslint-plugin'

export default defineConfig([
  stylistic.configs.recommended,   // 缩进、分号、引号等风格规则
])

这三者的关系可以用一句话概括:Airbnb 和 Standard 的经典实现仍带有旧配置时代的包袱,neostandard 面向 flat config 延续 Standard 风格,纯格式化规则则可交给 Stylistic。选规范时先看它是否原生支持当前 ESLint 主版本和 flat config,这直接决定了接入与后续升级成本。

4.4 与 Prettier 协作:两个名字很像的包

很多团队用 Prettier 管格式化、ESLint 管代码质量,两者分工明确。这个组合里有两个极易混淆的包:

包作用工作方式
eslint-config-prettier关闭 ESLint 中所有与 Prettier 冲突的格式化规则放在配置数组最后,纯"灭火"
eslint-plugin-prettier把 Prettier 作为一条 ESLint 规则来运行格式问题变成 lint 报错

两者解决的是同一个问题(两个工具抢格式化的话语权),但思路相反:前者让 ESLint 闭嘴、格式化完全交给 Prettier;后者让 ESLint 全权代理、顺带把格式也报了。

import eslintConfigPrettier from 'eslint-config-prettier'

export default defineConfig([
  // ...你的其他配置
  eslintConfigPrettier,   // 必须放最后,才能覆盖掉前面的格式化规则
])

社区主流建议是前者(eslint-config-prettier):格式化归 Prettier,代码质量归 ESLint,各干各的,编辑器里保存时分别执行,互不干扰。

4.5 框架与生态插件

框架类插件负责"框架特有的最佳实践",常见用法都是继承其扁平预设:

插件典型用途扁平预设示例
eslint-plugin-vueVue 模板语法检查、组件规范pluginVue.configs['flat/recommended']
eslint-plugin-reactJSX 规范、React 特有错误react.configs.flat.recommended
eslint-plugin-react-hooksHooks 依赖数组、调用顺序reactHooks.configs.flat.recommended
eslint-plugin-react-refresh保证组件文件只导出组件(配合 HMR)reactRefresh.configs.vite
eslint-plugin-import-ximport/export 语句检查(eslint-plugin-import 的活跃维护分支)importX.flatConfigs.recommended
eslint-plugin-unicorn一大批高质量的最佳实践规则unicorn.configs.recommended

Vite 官方 React 模板生成的配置就是这套组合的缩影(这也是本课程工程里 apps/web 在用的配置):

export default defineConfig([
  globalIgnores(['dist']),
  {
    files: ['**/*.{ts,tsx}'],
    extends: [
      js.configs.recommended,              // 官方防错规则
      tseslint.configs.recommended,        // TypeScript 支持
      reactHooks.configs.flat.recommended, // Hooks 规范
      reactRefresh.configs.vite,           // 热更新友好
    ],
    languageOptions: {
      globals: globals.browser,
    },
  },
])

可以看到,plugins 这个键甚至没有显式出现——预设通过 extends 已经把插件注册好了。这正是现代 ESLint 配置的主流写法:extends 管大头,plugins 只在需要手动挂载插件时才出场。


5. languageOptions —— 语言相关配置

languageOptions 回答"这些文件按什么语言环境来解析"。它有四个核心子项:

{
  files: ['**/*.js'],
  languageOptions: {
    ecmaVersion: 'latest',     // 语法版本:允许使用哪个年代的 JS 语法
    sourceType: 'module',      // 模块类型:script / module / commonjs
    globals: {                 // 预先声明的全局变量
      ...globals.node,
    },
    parser: espree,            // 解析器(默认 espree,TS 项目换成 ts 解析器)
    parserOptions: {},         // 传给解析器的额外参数
  },
}

5.1 ecmaVersion 与 sourceType:语法与模块的"方言开关"

  • ecmaVersion 默认是 'latest',即允许最新语法。如果你的代码要跑在老 Node 版本上,可以设为 2020 这样的年份,写新语法(如可选链 ?.)时会被拦下。
  • sourceType 决定 import/export 是否合法:'module'(ESM)、'script'(传统脚本,写了 import 就报错)、'commonjs'(允许 require)。默认值对 .js/.mjs 是 'module',对 .cjs 是 'commonjs',大部分项目不用动。

5.2 globals:告诉 ESLint "这些全局变量是环境提供的"

这是 languageOptions 里使用频率最高的子项。它解决的问题是:ESLint 不认识你的运行环境。

浏览器代码里的 document、window,Node 代码里的 process、__dirname,测试框架注入的 describe、it——这些标识符不是代码里声明的,no-undef 规则会一视同仁地报"未定义"。globals 就是白名单:

import globals from 'globals'

{
  files: ['tests/**/*.js'],
  languageOptions: {
    globals: {
      describe: 'readonly',   // 'readonly' = 允许读取,禁止重新赋值
      it: 'readonly',
    },
  },
}

每个全局变量的值有两种:'readonly'(只读,给 myGlobal = 1 赋值会报错)和 'writable'(可写)。除非有特殊理由,一律用 'readonly'——给全局变量重新赋值本来就是坏味道。

手写全局变量列表太累,社区维护的 globals 包把各环境的清单都整理好了,用展开运算符整体引入即可:

import globals from 'globals'

// 浏览器项目
languageOptions: { globals: { ...globals.browser } }

// Node 项目
languageOptions: { globals: { ...globals.node } }

常用的还有 globals.es2021(ES2021 的内置对象如 Promise)、globals.vitest、globals.jest 等。在 monorepo 里,前端包配 globals.browser、工具包配 globals.node,就是通过不同配置对象的 files + languageOptions 组合实现的。

5.3 parser:换解析器就是换"语法理解能力"

默认解析器 espree 只懂标准 JavaScript。TypeScript 项目必须把解析器换成 @typescript-eslint/parser——这就是 tseslint.configs.recommended 预设里默默帮你做的事情之一。如果不用预设、手工配置 TS 支持,写法是:

import tseslintParser from '@typescript-eslint/parser'

{
  files: ['**/*.ts'],
  languageOptions: {
    parser: tseslintParser,
    parserOptions: {
      projectService: true,   // 需要类型信息的规则要开这个
    },
  },
}

parserOptions 的内容因解析器而异,对 TS 解析器来说最重要的就是"要不要带类型信息"——带类型信息能检查出"忘了 await 的 Promise"这类深层次问题,代价是检查变慢。


6. rules —— 规则

rules 是配置的最终落点:前面所有配置都是为了"把正确的规则应用到正确的文件上"。每条规则的配置值有两部分:严重级别和规则参数。

rules: {
  'no-var': 'error',                    // 只有级别,用默认参数
  eqeqeq: ['error', 'always'],          // 数组:第一个元素是级别,后面是参数
  'no-console': ['warn', { allow: ['warn', 'error'] }],  // 参数也可以是对象
}

严重级别只有三种:

级别等价数字行为
'off'0关闭这条规则
'warn'1报告为警告,不影响退出码(CI 不挂)
'error'2报告为错误,命令以非零码退出

团队约定上一般只有"开(error)"和"关(off)"两态,warn 容易变成没人看的噪音。需要灰度上线某条新规则时,warn 是合理的过渡手段。

6.1 几条值得认识的内置规则

内置规则有二百多条,预设已经替你选好了大头,手工微调时最常打交道的其实就这几类:

rules: {
  // 防错类:拦住大概率是 bug 的写法
  eqeqeq: ['error', 'always'],    // 强制 === / !==,堵住 == 的隐式转换坑
  'no-var': 'error',              // 禁用 var,统一用 let / const
  'prefer-const': 'error',        // 不再重新赋值的 let 必须用 const
  'no-unused-vars': 'error',      // 未使用的变量(js/recommended 已含)
  curly: ['error', 'all'],        // if/for 等必须带大括号,杜绝单语句悬空

  // 环境适配类:按项目类型调整
  'no-console': 'warn',           // 限制 console,后端/库项目常开,前端可放宽
}

其中 eqeqeq 最值得单独说一句。JavaScript 的 == 会做隐式类型转换,0 == ''、null == undefined 都为真,这类"相等"几乎总是 bug 的温床。eqeqeq: ['error', 'always'] 强制全部使用严格相等,是性价比最高的一条规则。

no-unused-vars 有个细节值得知道:模块中 export 出去的符号不会被判定为"未使用",因为它们是这个模块对外的接口,ESLint 管不到外面的使用情况。所以这条规则只会抓模块内部的死变量。

6.2 哪些错误能 --fix,哪些不能

执行 npx eslint . --fix 时,ESLint 会自动修复"修复后绝不改变代码语义"的问题。这有个明确的边界:

  • ✅ 可自动修复:no-var(var → let/const)、prefer-const、多余分号等——替换前后行为完全一致;
  • ❌ 不可自动修复:no-unused-vars(删变量可能误伤)、eqeqeq(== 改 === 会改变运行结果)——这类规则只报告,需要你人工确认。

所以 eqeqeq 报错虽然烦,但它逼你确认"这里到底是想宽松比较还是写错了",这正是它的价值。本文第 7 节的 demo 里可以看到 --fix 前后文件的真实变化。

6.3 用后置配置对象做局部放宽

规则调整的经典场景:源码里禁 console,但脚本和测试里随便用。利用第 1 节讲的合并机制,追加一个后置对象即可:

export default defineConfig([
  {
    files: ['**/*.js'],
    rules: {
      'no-console': 'error',
    },
  },
  {
    // 后置对象:只对 scripts 和 tests 放宽
    files: ['scripts/**/*.js', 'tests/**/*.js'],
    rules: {
      'no-console': 'off',
    },
  },
])

规则层面的团队规范,本质就是"一份通用 rules + 若干按目录特化的后置对象"。抽包时抽走的是前者,各项目保留后者。


7. 实战验证:从零搭一份配置并跑通

下面把前面五节的内容组装成一份完整配置,从空目录开始,亲手验证每一条结论。全程使用 npm(pnpm、yarn 命令等价)。

7.1 创建项目与目录结构

mkdir eslint-demo && cd eslint-demo
npm init -y
npm pkg set type=module          # 让 eslint.config.js 可以用 ESM 语法
mkdir -p src tests dist

安装依赖(依赖版本:ESLint v10.7.0、@eslint/js v10.0.1、typescript-eslint v8.64.0、globals v17.7.0):

npm install -D eslint @eslint/js globals typescript-eslint typescript

四个包的分工:

  1. eslint 是本体
  2. @eslint/js 提供官方 js.configs.recommended 预设
  3. globals 提供各环境的全局变量清单
  4. typescript-eslint 是 TS 解析器 + 规则 + 预设的元包

7.2 准备"有问题"的源码

src/index.js——故意埋三个问题:var、未使用变量、==:

var count = 0

function add(a, b) {
  const unused = 123
  return a + b
}

if (count == '0') {
  console.log(add(1, 2))
}

tests/index.test.js——用到了测试框架注入的全局函数:

import { strictEqual } from 'node:assert'

// describe / it 是测试框架注入的全局函数
describe('add', () => {
  it('1 + 2 = 3', () => {
    strictEqual(1 + 2, 3)
  })
})

dist/bundle.js——模拟质量堪忧的构建产物,它不应该被检查:

var 打包产物 = '这段代码是构建工具生成的,质量再差也不该人工检查'

src/helper.ts——一个 TS 文件,先放一个简单的:

export const add = (a: number, b: number): number => a + b

当前目录结构:

eslint-demo/
├── dist/
│   └── bundle.js
├── src/
│   ├── helper.ts
│   └── index.js
├── tests/
│   └── index.test.js
├── eslint.config.js     ← 下一步创建
└── package.json

7.3 编写配置

创建 eslint.config.js,把五个配置项全部用上:

import js from '@eslint/js'
import globals from 'globals'
import { defineConfig, globalIgnores } from 'eslint/config'

export default defineConfig([
  // ignores:全局忽略构建产物,对所有配置对象生效
  globalIgnores(['dist/']),

  // files + languageOptions + rules:所有 JS 文件的通用方案
  {
    files: ['**/*.js'],
    extends: [js.configs.recommended],
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: {
        ...globals.node,
      },
    },
    rules: {
      'no-var': 'error',
      eqeqeq: ['error', 'always'],
      'prefer-const': 'warn',
    },
  },

  // 局部修正:测试文件追加测试框架的全局变量
  {
    files: ['tests/**/*.js'],
    languageOptions: {
      globals: {
        describe: 'readonly',
        it: 'readonly',
      },
    },
  },
])

7.4 第一次运行:观察检查范围

npx eslint .

实际输出:

/Users/jie/.../eslint-demo/src/index.js
  1:1   error  Unexpected var, use let or const instead     no-var
  4:9   error  'unused' is assigned a value but never used  no-unused-vars
  8:11  error  Expected '===' and instead saw '=='          eqeqeq

✖ 3 problems (3 errors, 0 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.

这次运行同时验证了三件事:

  1. 三个规则按预期触发,js.configs.recommended 提供的 no-unused-vars 也在其中;
  2. dist/bundle.js 完全安静——里面明明有 var,但全局忽略生效了;
  3. tests/index.test.js 没有报 no-undef——describe、it 被 languageOptions.globals 认领了。

但注意一个"沉默的漏网之鱼":src/helper.ts 根本没有出现在输出里。它不是被忽略了,而是没有任何配置对象认领 .ts 文件。单独指定它运行可以看得更清楚:

npx eslint src/helper.ts
/Users/jie/.../eslint-demo/src/helper.ts
  0:0  warning  File ignored because no matching configuration was supplied

✖ 1 problem (0 errors, 1 warning)

这就是第 2.1 节说的:非默认扩展名必须显式认领。

7.5 验证 --fix 的边界

npx eslint src/index.js --fix
/Users/jie/.../eslint-demo/src/index.js
  4:9   error  'unused' is assigned a value but never used  no-unused-vars
  8:11  error  Expected '===' and instead saw '=='          eqeqeq

✖ 2 problems (2 errors, 0 warnings)

再看修复后的文件,var count = 0 已经变成了 const count = 0(no-var 修复时发现 count 从未重新赋值,直接升级成了 const),而 no-unused-vars 和 eqeqeq 原样保留——印证了第 6.2 节的边界:自动修复只动"修复后语义不变"的问题,可能改变行为的报错留给人工确认。

7.6 加入 TypeScript 支持

在配置数组里追加一个配置对象(第 4.1 节讲过的标准写法):

import tseslint from 'typescript-eslint'

export default defineConfig([
  globalIgnores(['dist/']),

  // 新增:TypeScript 文件套用官方推荐预设
  {
    files: ['**/*.ts'],
    extends: [tseslint.configs.recommended],
  },

  // ...其余配置对象不变
])

为了看到效果,把 src/helper.ts 改得"坏"一点:

export const bad: any = 1
export const unusedFn = () => 42

再次运行 npx eslint .:

/Users/jie/.../eslint-demo/src/helper.ts
  1:19  error  Unexpected any. Specify a different type  @typescript-eslint/no-explicit-any

/Users/jie/.../eslint-demo/src/index.js
  4:9   error  'unused' is assigned a value but never used  no-unused-vars
  8:11  error  Expected '===' and instead saw '=='          eqeqeq

✖ 3 problems (3 errors, 0 warnings)

两个观察点:

  1. .ts 文件被正式接管,@typescript-eslint/no-explicit-any 规则名带着第 4 节讲过的插件命名空间前缀;
  2. unusedFn 没有被报未使用——它是 export 出去的模块接口,no-unused-vars 按第 6.1 节说的规则放行了它。

至此,五个配置项全部验证完毕:files 决定了 TS 文件从"无人认领"到"被检查";ignores 让 dist/ 全程安静;languageOptions.globals 让测试文件里的 describe/it 合法;extends 引入的预设带来了 @typescript-eslint 插件的规则;rules 里的手工条目则精确拦下了 var、== 和未使用变量。


写在最后

本文围绕五个配置项建立了扁平配置的完整心智模型,可以用一条链路串起来:

files 选中文件 → ignores 排除例外 → languageOptions 确定语言环境
    → plugins / extends 扩展规则集 → rules 做最终裁决

再叠加"数组后者覆盖前者"的合并机制,就得到了写配置的标准姿势:前面的对象定通用底线,后面的对象按目录特化。

关于第三方生态,记住三个判断就够了:

  1. 选规范先看扁平配置支持:Airbnb 官方包和 Standard 的经典用法仍带有旧配置时代的包袱;偏爱 Standard 风格时,可以评估原生支持 flat config 的 neostandard;
  2. 格式化交给 Prettier:用 eslint-config-prettier 关掉冲突规则(放配置数组最后),而不是让两个工具抢话语权;
  3. 框架插件用预设不用手写:extends: [xxx.configs.recommended] 一条顶几十条手写规则,plugins 键只在手动挂载时才需要出场。

最后,配置写出来一定要跑一次 npx eslint . 验证:看看该报的文件有没有报(规则生效)、不该报的文件是否安静(忽略生效)、新扩展名的文件是否被跳过(files 认领)。ESLint 不会主动告诉你"有文件没人管",这是新手最容易漏掉的验证项。

参考资料