工程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 | 给配置对象起名,用于报错信息和配置检查器中定位来源 |
files | glob 数组,声明这份配置作用于哪些文件 |
ignores | glob 数组,声明排除哪些文件 |
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/**/*.ts | src 目录(含子目录)下的 .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-eslint | TypeScript 解析器 + 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(),再看预设导出的是对象还是数组。
怎么判断一个陌生预设是对象还是数组?
两个办法:
- 一看插件文档,优先照当前版本的官方示例组合;
- 二在已安装的项目里跑一行直接验证:
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-vue | Vue 模板语法检查、组件规范 | pluginVue.configs['flat/recommended'] |
eslint-plugin-react | JSX 规范、React 特有错误 | react.configs.flat.recommended |
eslint-plugin-react-hooks | Hooks 依赖数组、调用顺序 | reactHooks.configs.flat.recommended |
eslint-plugin-react-refresh | 保证组件文件只导出组件(配合 HMR) | reactRefresh.configs.vite |
eslint-plugin-import-x | import/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
四个包的分工:
eslint是本体@eslint/js提供官方js.configs.recommended预设globals提供各环境的全局变量清单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.
这次运行同时验证了三件事:
- 三个规则按预期触发,
js.configs.recommended提供的no-unused-vars也在其中; dist/bundle.js完全安静——里面明明有var,但全局忽略生效了;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)
两个观察点:
.ts文件被正式接管,@typescript-eslint/no-explicit-any规则名带着第 4 节讲过的插件命名空间前缀;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 做最终裁决
再叠加"数组后者覆盖前者"的合并机制,就得到了写配置的标准姿势:前面的对象定通用底线,后面的对象按目录特化。
关于第三方生态,记住三个判断就够了:
- 选规范先看扁平配置支持:Airbnb 官方包和 Standard 的经典用法仍带有旧配置时代的包袱;偏爱 Standard 风格时,可以评估原生支持 flat config 的
neostandard; - 格式化交给 Prettier:用
eslint-config-prettier关掉冲突规则(放配置数组最后),而不是让两个工具抢话语权; - 框架插件用预设不用手写:
extends: [xxx.configs.recommended]一条顶几十条手写规则,plugins键只在手动挂载时才需要出场。
最后,配置写出来一定要跑一次 npx eslint . 验证:看看该报的文件有没有报(规则生效)、不该报的文件是否安静(忽略生效)、新扩展名的文件是否被跳过(files 认领)。ESLint 不会主动告诉你"有文件没人管",这是新手最容易漏掉的验证项。
