从 .eslintrc 到 eslint.config.mjs:一次升级让我重新理解了 ESLint
你升级了 ESLint,兴冲冲地重跑一遍 lint,结果屏幕上出现的不是熟悉的 warning 列表,而是一句陌生的报错:
text
ESLint couldn't find an eslint.config.(js|mjs|cjs) file.
你第一反应是:我的 .eslintrc.json 写得好好的,怎么突然找不到了?
不是你写错了,是 ESLint 10 把 .eslintrc 整个删了。
从 ESLint 9 开始,旧版 .eslintrc 被标记为 deprecated;到了 ESLint 10(2025 年 10 月发布),它被彻底移除 ------不是"不推荐用",是"读都不读"。你项目里那些 .eslintrc.json、.eslintrc.js、extends、env,在新版本面前等于空气。
这篇文章就是来解决这个问题的:为什么 ESLint 要删掉用了十年的配置格式,以及新的 flat config 到底怎么写。
一句话搞懂 flat config 是什么
flat config 就是:把原来"层层级联、extends 到处继承"的配置,拍平成一个 JS 数组。数组里每个对象就是一份完整、独立的配置块。
旧 .eslintrc 长这样(一个文件,一堆顶层字段):
json
{
"extends": ["eslint:recommended"],
"env": { "node": true },
"parserOptions": { "ecmaVersion": 2022, "sourceType": "module" },
"plugins": ["react"],
"rules": { "quotes": ["error", "double"] },
"overrides": [{ "files": ["*.test.js"], "env": { "jest": true } }]
}
新 flat config 长这样(一个 .mjs 文件,导出一个数组):
javascript
import js from "@eslint/js";
import globals from "globals";
export default [
{
files: ["**/*.{js,mjs,cjs}"],
plugins: { js },
extends: ["js/recommended"],
languageOptions: { globals: globals.node },
rules: {
"no-var": 2,
"no-console": 1,
"quotes": ["error", "double"],
"semi": ["error", "always"],
"indent": ["error", 2],
},
},
];
一眼能看出的区别:从"一棵树"变成了"一排卡片"。 没有 extends 的继承链了,没有 env 了,没有 overrides 了。
为什么 ESLint 要自断双臂?------这才是关键
很多人看到 flat config 的第一反应是"这不就是把 JSON 换成 JS 了吗,换汤不换药"。不是。 换成 JS 只是表象,背后是 ESLint 对"配置是怎么被解析出来的"这件事的彻底重做。
旧 .eslintrc 的解析方式叫 级联(cascade) 。当 ESLint 要检查 src/a.js 时,它会:
text
src/a.js
│
├─ 读 src/.eslintrc.json
├─ 再往上读 .eslintrc.json
├─ extends: "eslint:recommended" → 去 node_modules 解析这个共享配置
├─ extends: ["plugin:react/recommended"] → 再解析 react 插件的配置
└─ 把上面所有结果合并成一个"最终 config",才轮到检查文件
看懂问题了吗?为每个文件都要重新走一遍这条"向上找 + 解析 extends"的链路。 你的项目越大、共享配置越深,这个解析过程就越慢、越不可预测。
更要命的是隐式合并:最终生效的 config 到底是哪些文件拼出来的、拼的顺序是什么、谁覆盖了谁,你基本只能靠"试"。配置 bug 成了 ESLint 最让人头疼的问题------不是规则写错了,而是"这条规则其实没生效,因为被另一个 extends 悄悄覆盖了"。
flat config 的答案是釜底抽薪:配置就是一段 JS,你导出一个数组,ESLint 按数组顺序读,谁在前谁在后一目了然。 没有级联、没有隐式合并、没有 extends 解析------extends: "eslint:recommended" 变成了显式的 import + plugins + extends。
一句话总结这个"为什么":
旧
.eslintrc是"运行时递归拼配置",flat config 是"你写什么就是什么,顺序即优先级"。前者省了几行配置,代价是解析慢、结果不可预测;后者多写几个 import,换来确定性和可调试性。
这就是 ESLint 团队敢删 .eslintrc 的底气------不是新瓶装旧酒,是把"配置解析"这个烂摊子整个重写了。
逐行拆解:这 10 行代码到底在做什么
下面这份就是完整可跑的 eslint.config.mjs(这个 demo 里就在用):
javascript
// 这个文件是 ESLint 的 flat config:导出一个配置对象数组
import js from "@eslint/js"; // ① 内置推荐规则的官方包
import globals from "globals"; // ② 全局变量包(替代旧 env)
export default [
{
files: ["**/*.{js,mjs,cjs}"], // ③ 这份配置作用在哪些文件
plugins: { js }, // ④ 把 @eslint/js 挂成一个叫 js 的插件
extends: ["js/recommended"], // ⑤ 引用这个插件里的 recommended 配置
languageOptions: {
globals: globals.node, // ⑥ 声明 Node 全局变量
},
rules: {
"no-var": 2, // ⑦ 规则列表(2=error 1=warn 0=off)
"no-console": 1,
"quotes": ["error", "double"],
"semi": ["error", "always"],
"indent": ["error", 2],
},
},
];
逐个解释几个最容易被卡住的点:
① import js from "@eslint/js" --- 旧版 extends: "eslint:recommended" 是 ESLint 内置的,不用装。新版把它拆成了独立的 @eslint/js 包,得 npm i -D @eslint/js。这个包导出的 js 对象里带着 recommended 这份配置。
② import globals from "globals" --- 旧版写 "env": { "node": true } 就声明了 process、console、__dirname 这些 Node 全局变量。新版改成装 globals 包,然后 globals.node。这是迁移时最容易被忽略的一步 ------漏了它,process 会被当成未定义变量报 no-undef。
④ + ⑤ 为什么 extends 还要留着? 注意这里 extends: ["js/recommended"] 不是旧版那种"字符串去 node_modules 找"。它是"在我第 ④ 步挂载的 js 插件里,找名叫 recommended 的那份配置"。本质已经从"继承"变成"引用本地变量"了。
⑦ 数字 vs 字符串 --- "no-var": 2 里的 2 是历史遗留的简写:0 = off、1 = warn、2 = error。flat config 里两种都能用,写成 "error" 更可读,但 demo 里用数字是为了演示这个映射关系。
新旧映射表:照着改就能迁移
把旧 .eslintrc 的每个字段搬到 flat config,对照这张表:
.eslintrc(旧) |
flat config(新) | 说明 |
|---|---|---|
extends: "eslint:recommended" |
import js from "@eslint/js" + plugins: { js } + extends: ["js/recommended"] |
内置推荐规则拆成独立包 |
env: { node: true } |
import globals from "globals" + languageOptions: { globals: globals.node } |
全局变量改成显式包 |
parserOptions: { ecmaVersion, sourceType } |
languageOptions: { ecmaVersion, sourceType } |
字段挪进 languageOptions |
plugins: ["react"] |
plugins: { react } |
从字符串数组变成 import 对象 |
rules: { ... } |
rules: { ... } |
完全不变 |
overrides: [{ files, rules }] |
数组里每个对象自带 files 字段 |
overrides 概念消失,每块配置独立声明作用文件 |
注意最后一行的深意:旧版用 overrides 去"覆盖"默认配置,新版没有"默认 + 覆盖"这回事了------每份配置自己声明 files 作用域,数组顺序就是覆盖顺序。 后写的配置块,规则优先级更高。
实验:这个 demo 到底抓到了什么
光讲概念没意思,跑一遍给你看真实输出。这个 demo 里有一个故意写得不干净的 index.mjs:
javascript
let name = "wuxianhong";
let a = 1;
function hello() {
console.log(name + "hello");
}
hello();
执行 npx eslint .,真实输出:
text
C:\...\eslint-demo\index.mjs
2:5 error 'a' is assigned a value but never used no-unused-vars
4:3 warning Unexpected console statement no-console
✖ 2 problems (1 error, 1 warning)
这里藏着三个值得记住的点:
① 真正帮你抓 bug 的,是 js/recommended 自带的 no-unused-vars,不是你手写的那几条规则。 let a = 1; 声明了却从没用到,被标成 error。这正是 extends: ["js/recommended"] 的价值------它打包了一堆"潜在 bug 检测"规则,你一条没写也在生效。
② no-console 报的是 warning 而不是 error ,因为 demo 里写的是 "no-console": 1(1 = warn)。所以 lint 虽然报问题,但退出码是 1 是因为有 error,warning 本身不会让 CI 挂。
③ 那几条 quotes / semi / indent 规则"没反应" ------ 不是因为失效,是因为这份代码刚好已经用了双引号、加分号、2 空格缩进。规则只在你违反它时显形,这恰恰说明"配了规则 ≠ 规则在干活",得拿一条真的会违反的代码去验证它。
再补一刀:--fix 不是万能的
很多人以为 eslint . --fix 能一键修复所有问题。跑一下:
text
$ npx eslint . --fix
C:\...\eslint-demo\index.mjs
2:5 error 'a' is assigned a value but never used no-unused-vars
4:3 warning Unexpected console statement no-console
✖ 2 problems (1 error, 1 warning)
结果一模一样,什么都没修。 因为 no-unused-vars 和 no-console 是不可自动修复的规则------ESLint 没法替你判断"这个没用的变量是删掉还是你本来想用",也没法替你决定"这个 console.log 是调试残留还是真的日志"。
只有格式类 规则(quotes、semi、indent、comma-dangle)能被 --fix 自动修。逻辑类、语义类规则只能报,不能修。
这个认知很重要 :如果你希望 lint 在 CI 里自动修格式、只把"必须人工看"的问题留给开发者,就要区分这两类规则------格式类交给 --fix,语义类让它报出来。
两个我在真实项目里踩过的坑
坑一:type: commonjs 和 .mjs 打架
看这个 demo 的 package.json:
json
{
"type": "commonjs",
"main": "index.js",
"scripts": { "lint": "eslint ." }
}
"type": "commonjs" 意味着 .js 文件按 CommonJS 解析,但配置文件是 .mjs、入口是 index.mjs。这里 .mjs 后缀强制 ESM,所以能跑------但 main 字段写的是 index.js,实际文件却是 index.mjs ,你 node . 会直接 Cannot find module。
迁移到 flat config 时,最省心的做法是:配置文件统一用 eslint.config.mjs(.mjs 永远按 ESM 解析,不受 package.json 的 type 影响),别让 type 字段和文件后缀互相打架。
坑二:漏装 globals,报一堆 no-undef
迁移时最容易漏的一步。旧版 env: { node: true } 是"免费"的,新版要 npm i -D globals 再 languageOptions: { globals: globals.node }。漏了它,项目里所有 process、console、__dirname 全报 'process' is not defined,你会以为是规则配错,其实是全局变量没声明。
总结:记住这三件事
第一句(迁移口诀) :extends 换 @eslint/js,env 换 globals,overrides 换成数组里的 files,rules 原样不动。
第二句(理解本质):
flat config 不是"把 JSON 换成 JS",而是把"运行时递归拼配置"改成"你写的数组顺序即优先级"。
第三句(一个行为改变) :下次再看到 .eslintrc 教程,先看一眼 ESLint 版本------9 是过渡,10 已经删了,照着旧教程配只会得到一句 "couldn't find an eslint.config file"。
一个开放问题 :你的项目还在用 .eslintrc 吗?迁移到 flat config 时,是 env 的 globals 替换卡住了你,还是某个自定义插件(比如 eslint-plugin-react 的新版写法)卡住了你?评论区聊聊,我帮你对。