ESLint Flat Config 迁移指南:从 .eslintrc 到 eslint.config.js 的完整实践
ESLint Flat Config 迁移指南:从 .eslintrc 到 eslint.config.js 的完整实践
ESLint v9 正式将 Flat Config 作为默认配置系统,旧的 .eslintrc.* 格式进入废弃期。这不只是换个文件名——Flat Config 重新设计了配置的组织方式,解决了旧系统中层叠合并、插件解析等长期痛点。本文将带你理解新系统的设计原理,并完成一次完整的迁移实践。
一、为什么需要 Flat Config
1.1 旧配置系统的痛点
痛点一:配置层叠地狱
旧系统支持多种配置文件格式和层叠:
project/├── .eslintrc.json # 项目根配置├── src/│ ├── .eslintrc.yml # src 目录配置(覆盖根配置)│ └── components/│ └── .eslintrc.js # 再覆盖一层├── package.json # eslintConfig 字段也是配置└── node_modules/ └── some-config/ └── .eslintrc.json # 共享配置内部可能还有ESLint 需要沿目录树向上搜索配置文件,然后合并。这导致:
- 配置来源难以追踪
- 合并规则不直观
root: true用来截断搜索,经常被遗忘
痛点二:extends 的隐式解析
{ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:vue/vue3-recommended" ]}"plugin:xxx/yyy" 是什么意思?ESLint 需要:
- 找到
eslint-plugin-xxx包 - 读取它导出的
configs.yyy - 合并到当前配置
这个隐式的解析过程让配置变得不透明。
痛点三:插件的字符串引用
{ "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-unused-vars": "error" }}"@typescript-eslint" 只是一个字符串,ESLint 需要通过 require() 去查找 @typescript-eslint/eslint-plugin。这在 monorepo 或 pnpm 严格模式下经常出问题。
1.2 Flat Config 的解决方案
// eslint.config.js — 一切都是显式的 JavaScriptimport tseslint from 'typescript-eslint';import pluginVue from 'eslint-plugin-vue';
export default [ // 数组中的每个元素是一个配置对象 // 按顺序合并,后面的覆盖前面的 ...tseslint.configs.recommended, ...pluginVue.configs['flat/recommended'], { rules: { 'no-console': 'warn', }, },];核心改变:
- 单一配置文件:只有一个
eslint.config.js(或.mjs/.cjs) - 显式导入:插件通过
import引入,不再是字符串 - 扁平数组:配置是一个数组,按顺序合并
- 不再有 extends:直接展开共享配置
二、Flat Config 核心概念
2.1 配置数组
export default [ // 配置对象 1:全局设置 { languageOptions: { ecmaVersion: 2024, sourceType: 'module', }, },
// 配置对象 2:只对 TypeScript 文件生效 { files: ['**/*.ts', '**/*.tsx'], rules: { 'no-undef': 'off', // TypeScript 处理这个 }, },
// 配置对象 3:忽略文件 { ignores: ['dist/', 'node_modules/', '*.min.js'], },];2.2 files 和 ignores
files 和 ignores 使用 glob 模式,类似 .gitignore:
export default [ // 全局忽略(只有 ignores,没有其他属性) { ignores: [ 'dist/**', 'node_modules/**', 'coverage/**', '*.config.js', // 忽略所有配置文件 '!eslint.config.js', // 但不忽略 ESLint 配置本身 ], },
// 匹配特定文件 { files: ['src/**/*.ts'], rules: { /* TypeScript 规则 */ }, },
// 匹配测试文件 { files: ['**/*.test.ts', '**/*.spec.ts', 'tests/**'], rules: { 'no-console': 'off', }, },];重要区别:
- 只有
ignores的配置对象 = 全局忽略(替代.eslintignore) - 有
files+ignores的配置对象 = 在匹配的文件中排除某些
2.3 languageOptions
替代旧的 env、parserOptions、parser、globals:
import globals from 'globals';
export default [ { languageOptions: { // 替代 parserOptions.ecmaVersion ecmaVersion: 2024,
// 替代 parserOptions.sourceType sourceType: 'module',
// 替代 env(使用 globals 包) globals: { ...globals.browser, ...globals.es2024, ...globals.node, // 自定义全局变量 myGlobal: 'readonly', __DEV__: 'readonly', },
// 替代 parser(直接传入解析器对象) // parser: tsParser,
// 替代 parserOptions parserOptions: { ecmaFeatures: { jsx: true, }, }, }, },];2.4 plugins
插件不再是字符串,而是直接导入的对象:
import tsPlugin from '@typescript-eslint/eslint-plugin';import importPlugin from 'eslint-plugin-import-x';
export default [ { plugins: { // key 是命名空间前缀,value 是插件对象 '@typescript-eslint': tsPlugin, 'import-x': importPlugin, }, rules: { '@typescript-eslint/no-unused-vars': 'error', 'import-x/order': 'error', }, },];三、迁移实战
3.1 迁移前的旧配置
假设你的项目有这样的旧配置:
{ "root": true, "env": { "browser": true, "es2024": true, "node": true }, "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:vue/vue3-recommended", "prettier" ], "parser": "vue-eslint-parser", "parserOptions": { "parser": "@typescript-eslint/parser", "ecmaVersion": "latest", "sourceType": "module" }, "plugins": ["@typescript-eslint"], "rules": { "no-console": "warn", "no-debugger": "error", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "vue/multi-word-component-names": "off", "vue/no-v-html": "off" }, "overrides": [ { "files": ["*.test.ts", "*.spec.ts"], "env": { "jest": true }, "rules": { "no-console": "off" } } ]}3.2 迁移后的新配置
import globals from 'globals';import tseslint from 'typescript-eslint';import pluginVue from 'eslint-plugin-vue';import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config( // 全局忽略 { ignores: [ 'dist/**', 'node_modules/**', 'coverage/**', '*.d.ts', ], },
// 基础配置 { languageOptions: { ecmaVersion: 2024, sourceType: 'module', globals: { ...globals.browser, ...globals.es2024, ...globals.node, }, }, },
// ESLint 推荐规则 // eslint v9+ 导出为 flat config { rules: { // eslint:recommended 规则已内置在 tseslint.configs.recommended 中 }, },
// TypeScript 推荐规则 ...tseslint.configs.recommended,
// Vue 推荐规则 ...pluginVue.configs['flat/recommended'],
// Vue 文件的解析器配置 { files: ['**/*.vue'], languageOptions: { parserOptions: { parser: tseslint.parser, }, }, },
// 自定义规则 { rules: { 'no-console': 'warn', 'no-debugger': 'error', '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', }], 'vue/multi-word-component-names': 'off', 'vue/no-v-html': 'off', }, },
// 测试文件 { files: ['**/*.test.ts', '**/*.spec.ts', 'tests/**'], languageOptions: { globals: { ...globals.jest, }, }, rules: { 'no-console': 'off', '@typescript-eslint/no-explicit-any': 'off', }, },
// Prettier 兼容(必须放最后) eslintConfigPrettier,);3.3 安装依赖
# 移除旧依赖pnpm remove eslint-plugin-vue @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-config-prettier
# 安装新依赖pnpm add -D eslint@^9 typescript-eslint eslint-plugin-vue eslint-config-prettier globals3.4 清理旧文件
# 删除旧配置文件rm .eslintrc.json .eslintrc.js .eslintrc.yml .eslintrcrm .eslintignore # 不再需要,用 ignores 替代3.5 更新 package.json
{ "scripts": { "lint": "eslint .", "lint:fix": "eslint . --fix" }}注意:ESLint v9 默认使用 Flat Config,不需要任何额外标志。
四、typescript-eslint 的 Flat Config 用法
4.1 基础用法
import tseslint from 'typescript-eslint';
export default tseslint.config( // tseslint.config() 是一个辅助函数,提供类型提示 ...tseslint.configs.recommended,);4.2 类型感知的规则
如果你想启用需要类型信息的规则(更强大但更慢):
import tseslint from 'typescript-eslint';
export default tseslint.config( ...tseslint.configs.recommendedTypeChecked, { languageOptions: { parserOptions: { // 启用类型感知 projectService: true, tsconfigRootDir: import.meta.dirname, }, }, }, // 非 TS 文件禁用类型感知规则 { files: ['**/*.js', '**/*.mjs'], ...tseslint.configs.disableTypeChecked, },);4.3 严格模式
// 从宽松到严格...tseslint.configs.recommended, // 基础推荐...tseslint.configs.strict, // 更严格...tseslint.configs.strictTypeChecked, // 最严格 + 类型感知
// 风格相关...tseslint.configs.stylistic, // 风格规则...tseslint.configs.stylisticTypeChecked, // 风格 + 类型感知五、常见插件迁移
5.1 eslint-plugin-import → eslint-plugin-import-x
原版 eslint-plugin-import 对 Flat Config 支持不佳,推荐使用 fork 版本 eslint-plugin-import-x:
pnpm add -D eslint-plugin-import-ximport importX from 'eslint-plugin-import-x';
export default [ importX.flatConfigs.recommended, importX.flatConfigs.typescript, { rules: { 'import-x/order': ['error', { groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling', 'index', ], 'newlines-between': 'always', alphabetize: { order: 'asc', caseInsensitive: true, }, }], 'import-x/no-duplicates': 'error', 'import-x/no-unresolved': 'off', // TypeScript 处理 }, },];5.2 eslint-plugin-vue
Vue 插件已经原生支持 Flat Config:
import pluginVue from 'eslint-plugin-vue';
export default [ // 选择一个预设 ...pluginVue.configs['flat/recommended'], // 或 // ...pluginVue.configs['flat/essential'], // ...pluginVue.configs['flat/strongly-recommended'],
{ files: ['**/*.vue'], rules: { 'vue/block-order': ['error', { order: ['script', 'template', 'style'], }], 'vue/define-macros-order': ['error', { order: ['defineProps', 'defineEmits'], }], 'vue/no-unused-refs': 'error', 'vue/padding-line-between-blocks': 'error', }, },];5.3 Prettier 集成
import eslintConfigPrettier from 'eslint-config-prettier';
export default [ // ... 其他配置 // Prettier 必须放最后,关闭所有与格式化冲突的规则 eslintConfigPrettier,];六、自定义规则
Flat Config 让自定义规则变得更简单:
6.1 内联自定义规则
const noFooBarRule = { meta: { type: 'suggestion', docs: { description: 'Disallow variable names "foo" and "bar"', }, fixable: 'code', schema: [], messages: { noFooBar: 'Variable name "{{name}}" is not allowed. Use meaningful names.', }, }, create(context) { return { VariableDeclarator(node) { if ( node.id.type === 'Identifier' && ['foo', 'bar', 'baz'].includes(node.id.name) ) { context.report({ node: node.id, messageId: 'noFooBar', data: { name: node.id.name }, }); } }, }; },};
export default [ { plugins: { custom: { rules: { 'no-foo-bar': noFooBarRule, }, }, }, rules: { 'custom/no-foo-bar': 'error', }, },];6.2 完整的自定义插件
import noMagicNumbers from './rules/no-magic-numbers.js';import requireJsdoc from './rules/require-jsdoc.js';import noTodoWithoutIssue from './rules/no-todo-without-issue.js';
const plugin = { meta: { name: 'eslint-plugin-my-team', version: '1.0.0', }, rules: { 'no-magic-numbers': noMagicNumbers, 'require-jsdoc': requireJsdoc, 'no-todo-without-issue': noTodoWithoutIssue, }, configs: {},};
// 自引用配置(Flat Config 风格)plugin.configs.recommended = [ { plugins: { 'my-team': plugin, }, rules: { 'my-team/no-magic-numbers': 'warn', 'my-team/no-todo-without-issue': 'error', }, },];
export default plugin;export default { meta: { type: 'suggestion', docs: { description: 'Require TODO comments to include an issue link', }, schema: [{ type: 'object', properties: { pattern: { type: 'string' }, }, additionalProperties: false, }], messages: { missingIssue: 'TODO comment must include an issue link (e.g., TODO(#123))', }, }, create(context) { const pattern = context.options[0]?.pattern || '#\\d+'; const issueRegex = new RegExp(pattern);
return { Program() { const comments = context.sourceCode.getAllComments(); for (const comment of comments) { const value = comment.value.trim(); if (/\bTODO\b/i.test(value) && !issueRegex.test(value)) { context.report({ node: comment, messageId: 'missingIssue', }); } } }, }; },};使用自定义插件:
import myTeamPlugin from './plugins/eslint-plugin-my-team/index.js';
export default [ ...myTeamPlugin.configs.recommended, { rules: { 'my-team/require-jsdoc': ['error', { require: { FunctionDeclaration: true }, }], }, },];七、高级配置技巧
7.1 配置工厂函数
封装可复用的配置:
import globals from 'globals';import tseslint from 'typescript-eslint';import pluginVue from 'eslint-plugin-vue';import eslintConfigPrettier from 'eslint-config-prettier';
export function createConfig(options = {}) { const { vue = false, typescript = true, prettier = true, testFramework = 'vitest', } = options;
const configs = [ // 全局忽略 { ignores: ['dist/**', 'node_modules/**', 'coverage/**'], }, // 基础 { languageOptions: { ecmaVersion: 2024, sourceType: 'module', globals: { ...globals.browser, ...globals.node, }, }, }, ];
// TypeScript if (typescript) { configs.push(...tseslint.configs.recommended); }
// Vue if (vue) { configs.push( ...pluginVue.configs['flat/recommended'], { files: ['**/*.vue'], languageOptions: { parserOptions: { parser: tseslint.parser, }, }, }, ); }
// 测试文件 const testGlobals = testFramework === 'vitest' ? { describe: 'readonly', it: 'readonly', expect: 'readonly', vi: 'readonly', beforeEach: 'readonly', afterEach: 'readonly' } : globals.jest;
configs.push({ files: ['**/*.test.*', '**/*.spec.*', 'tests/**'], languageOptions: { globals: testGlobals }, rules: { 'no-console': 'off' }, });
// Prettier(必须最后) if (prettier) { configs.push(eslintConfigPrettier); }
return configs;}使用:
import { createConfig } from './configs/base.js';
export default [ ...createConfig({ vue: true, testFramework: 'vitest' }), // 项目特定规则 { rules: { 'no-console': 'warn', }, },];7.2 条件配置
import tseslint from 'typescript-eslint';
const isCI = process.env.CI === 'true';
export default tseslint.config( ...tseslint.configs.recommended,
// CI 环境更严格 ...(isCI ? [{ rules: { 'no-console': 'error', 'no-debugger': 'error', '@typescript-eslint/no-explicit-any': 'error', }, }] : [{ rules: { 'no-console': 'warn', 'no-debugger': 'warn', '@typescript-eslint/no-explicit-any': 'warn', }, }]),);7.3 Monorepo 中的配置共享
import globals from 'globals';import tseslint from 'typescript-eslint';
export const base = tseslint.config( ...tseslint.configs.recommended, { languageOptions: { ecmaVersion: 2024, globals: { ...globals.browser, ...globals.node }, }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', }], }, },);
// 各子包使用// apps/web/eslint.config.jsimport { base } from '@monorepo/eslint-config';
export default [ ...base, { rules: { // 项目特定规则 }, },];八、调试与排查
8.1 配置检查器
ESLint v9 提供了内置的配置检查器:
# 启动配置检查器 Web UInpx eslint --inspect-config
# 或者npx @eslint/config-inspector这会打开一个浏览器界面,让你可视化地查看:
- 每个配置对象的内容
- 规则的最终值
- 每个文件匹配了哪些配置
8.2 调试特定文件
# 查看某个文件的最终配置npx eslint --print-config src/App.vue
# 查看某个文件匹配了哪些规则npx eslint --debug src/App.vue 2>&1 | grep "config"8.3 常见迁移错误
错误一:忘记 flat/ 前缀
// ❌ 旧格式...pluginVue.configs['vue3-recommended'],
// ✅ Flat Config 格式...pluginVue.configs['flat/recommended'],错误二:插件格式错误
// ❌ 旧格式(字符串){ plugins: ['@typescript-eslint'] }
// ✅ Flat Config(对象)import tsPlugin from '@typescript-eslint/eslint-plugin';{ plugins: { '@typescript-eslint': tsPlugin } }
// ✅✅ 更推荐:使用 typescript-eslint 的便捷导出import tseslint from 'typescript-eslint';...tseslint.configs.recommended错误三:globals 未安装
# globals 包提供浏览器、Node.js 等环境的全局变量定义pnpm add -D globals九、与编辑器集成
9.1 VS Code
确保安装了 ESLint 扩展(dbaeumer.vscode-eslint),v3+ 版本原生支持 Flat Config。
{ "eslint.useFlatConfig": true, "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "eslint.validate": [ "javascript", "typescript", "vue" ]}9.2 与 lint-staged 集成
export default { '*.{js,ts,vue}': ['eslint --fix'], '*.{json,md,yml}': ['prettier --write'],};{ "scripts": { "prepare": "husky" }}npx lint-staged十、总结
Flat Config 迁移清单:
- ✅ 升级 ESLint 到 v9+
- ✅ 安装
globals包 - ✅ 创建
eslint.config.js(ESM 格式) - ✅ 将
extends改为直接import并展开 - ✅ 将
env改为languageOptions.globals - ✅ 将
parser改为languageOptions.parser(直接传对象) - ✅ 将
plugins从字符串改为导入的对象 - ✅ 将
overrides改为带files的配置对象 - ✅ 将
.eslintignore内容移到全局ignores - ✅ 删除旧的
.eslintrc.*和.eslintignore
Flat Config 的本质是让 ESLint 配置回归 JavaScript 的本质——一切都是显式的、可追踪的、可调试的。不再有魔法字符串、隐式解析和复杂的层叠规则。如果你还在用旧配置,现在是迁移的最佳时机。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!