前言
最近在从零搭建一个 Vue3 项目,模仿 Art Design Pro 的架构思路自己写。本来想直接开干页面,但想了想,如果一开始不把工程化规范搭建好,后面代码越多越难管。到时候缩进不统一、引号单双混用、提交信息乱七八糟,该起更痛苦。
所以这篇文章记录一下我的工程化搭建过程。目标很简单:让代码规范不靠人自觉,而是靠工具自动执行。
整体思路
工程化规范不是装一个 Prettier 就完事了,它是一条链:
text
编辑器层 → .editorconfig + .vscode/settings.json
格式化层 → Prettier
质量层 → ESLint
提交层 → Husky + lint-staged + commitlint
环境层 → .env 多环境
核心原则:能自动做的,绝不靠人手动。
第一步:.editorconfig ------ 统一编辑器基础行为
.editorconfig 是所有工程化的地基。它不依赖任何构建工具,装个编辑器插件就能生效,管的是最基础的格式:缩进、换行、字符集。
-
在 VSCode 中搜索插件 EditorConfig 插件进行安装
-
在项目根目录创建
.editorconfig:
ini
# EditorConfig is awesome:https://EditorConfig.org
# 根配置文件,阻止向上查找
root = true
# 所有文件适用的通用规则
[*]
# 文件字符集
charset = utf-8
# 换行符:统一使用 LF,解决 windows(CRLF)与 Unix(LF)的冲突
end_of_line = lf
# 缩进风格:空格
indent_style = space
# 缩进大小:2 个空格
indent_size = 2
# 去除行尾空白字符
trim_trailing_whitespace = true
# 文件末尾始终插入一个空行
insert_final_newline = true
# Matches 文件保留行尾空白(Markdown 中行尾双空格表示换行)
[*.md]
trim_trailing_whitespace = false
# Makefile 必须使用 Tab 缩进
[Makefile]
indent_style = tab
insert_final_newline = false
# Windows 批处理脚本使用 CRLF
[*.{bat,cmd}]
end_of_line = crlf
[*.{json,yml,yaml}]
indent_size = 2
第二步:Prettier ------ 统一代码格式
EditorConfig 解决了缩进、换行符、字符集等基础格式,但管不了代码层面的风格:引号用单还是双、要不要分号、多少字符换行。这些由 Prettier 负责。
1. 安装 prettier
bash
pnpm add -D prettier
2. 创建配置文件
在项目根目录创建 .prettierrc.mjs
json
/**
* Prettier 配置文件 (.prettierrc.mjs)
*
* 核心原则:
* 1. 此文件集中配置 Prettier 的格式化规则。
* 2. 未在此处显式配置的缩进、换行符等选项,由 Prettier 从 .editorconfig 读取。
* 3. 使用 .mjs 格式是为了支持注释,方便维护。
*/
/** @type {import("prettier").Config} */
export default {
printWidth: 120, // 每行尽量控制在 120 列以内;这是换行参考值,不是硬性上限。
singleQuote: true, // JavaScript、TypeScript 等适用语法优先使用单引号;JSON 仍按语法要求使用双引号。
semi: true, // 在语句末尾添加分号。
trailingComma: 'all', // 多行对象、数组、参数等语法结构末尾尽可能添加逗号。
arrowParens: 'always', // 箭头函数参数始终保留括号,例如 `(value) => value`。
proseWrap: 'preserve', // 保留 Markdown 等文本段落现有的换行方式。
vueIndentScriptAndStyle: false, // Vue 单文件组件中的 script/style 内容不相对标签额外缩进。
};
3. 创建忽略文件
Prettier 默认会格式化项目里所有文件,但有些文件不该被它碰,比如构建产物、依赖目录、锁文件。根目录中创建 .prettierignore:
json
# 依赖与构建产物
node_modules
dist
dist-ssr
build
coverage
# 缓存
.cache
.vite
# 锁文件
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lockb
# 本地环境文件
.env
.env.local
.env.*.local
# 系统与编辑器
.DS_Store
.idea
# 文档
*.md
为什么忽略 Markdown?
因为 proseWrap: 'preserve' 已经保留了 Markdown 的换行,但 Prettier 仍可能调整表格对齐、列表缩进等。如果你写的是掘金这类需要精确控制排版的内容,建议直接忽略,避免 Prettier 好心办坏事。
4. 添加脚本
在 package.json 中添加:
json
{
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}
format:格式化所有文件,直接改磁盘内容format:check:只检查不修改,有问题返回非零退出码,用于 CI
5. 与 EditorConfig 的关系
两者有重叠的部分(如缩进、换行符),但职责不同:
- EditorConfig:管编辑器层面的基础格式,跨编辑器通用,不依赖任何构建工具
- Prettier:管代码格式化规则,需要手动或通过插件触发
实际使用中,Prettier 会读取 .editorconfig 里它支持的选项(如 end_of_line、indent_size),所以两边保持一致即可。Prettier 不支持的选项(如 charset),由 EditorConfig 单独生效。
6. 验证
执行:
bash
pnpm format
然后随便找个文件,故意把缩进改乱、把单引号改成双引号,保存后再跑一次 pnpm format,看是否被自动修正。如果 VS Code 里保存没反应,检查 .vscode/settings.json 中的 editor.defaultFormatter 是否配了 esbenp.prettier-vscode。
第三步:VS Code 配置 ------ 让规范自动执行
前面配置好了 .editorconfig 和 .prettierrc,但如果每次都要手动跑 pnpm format,那和没配一样。.vscode 里的配置,就是让编辑器在保存时自动执行这些规则。
需要提交哪些文件
text
.vscode/
├── settings.json # 工作区设置(必须提交)
└── extensions.json # 推荐插件(必须提交)
launch.json 和 tasks.json 属于可选,不是每个项目都需要。核心是前两个。
settings.json
创建 .vscode/settings.json:
json
{
// === 编辑器基础 ===
"editor.formatOnSave": true, // 保存时自动格式化
"editor.codeActionsOnSave": {
"source.fixAll.stylelint": "explicit" // 显式保存时应用 Stylelint 自动修复
},
"files.eol": "\n", // 与 .editorconfig 保持一致
// === 默认格式化器 ===
"editor.defaultFormatter": "esbenp.prettier-vscode",
// === 按语言指定格式化器(覆盖默认) ===
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[markdown]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
// === Stylelint ===
"stylelint.validate": ["css", "scss", "vue", "html"],
// === 文件排除(让侧边栏更干净) ===
"files.exclude": {
"**/.git": true,
"**/node_modules": true,
"**/dist": true,
"**/.turbo": true
},
"search.exclude": {
"**/node_modules": true,
"**/bower_components": true,
"**/*.code-search": true
},
// === Volar ===
"vue.server.hybridMode": true, // Volar 2.x 的推荐设置
// === 关闭 VS Code 内置的样式校验,交给 Stylelint ===
"css.validate": false,
"scss.validate": false,
"less.validate": false
}
注意: settings.json 里别放个人偏好(字体、主题色、字号),那些属于用户级 settings,提交了会影响别人。工作区 settings 只放和项目规范相关的。
extensions.json
创建 .vscode/extensions.json
json
{
"recommendations": [
// === Vue3 核心 ===
"Vue.volar", // Vue - Official:Vue3 官方语言支持
// === 代码质量与格式化 ===
"dbaeumer.vscode-eslint", // ESLint:代码质量检查
"esbenp.prettier-vscode", // Prettier:代码格式化
"stylelint.vscode-stylelint", // Stylelint:CSS/SCSS/Vue 样式检查
"EditorConfig.EditorConfig", // 读取 .editorconfig,统一缩进与换行符
// === 开发体验(可选) ===
"usernamehw.errorlens", // 行内显示错误与警告
"streetsidesoftware.code-spell-checker", // 拼写检查
"christian-kohler.path-intellisense", // 路径智能补全
"mikestead.dotenv", // .env 文件语法高亮
"gruntfuggly.todo-tree" // 以树状视图汇总 TODO
],
"unwantedRecommendations": [
// 提示不要使用与 Volar 冲突的扩展;此设置不会自动禁用扩展
"octref.vetur" // Vetur:Vue2 项目使用的 Vue 语言扩展
]
}
别人克隆项目后,VS Code 会提示"是否安装推荐插件",点一下就能装齐,不用手动找。
验证
随便找个文件,把缩进改乱、引号改成双引号,按 Ctrl/Cmd + S 保存,看是否自动格式化。
如果没生效:
- 确认已安装
esbenp.prettier-vscode插件 - 确认
.vscode/settings.json里editor.defaultFormatter配的是esbenp.prettier-vscode - 检查 VS Code 右下角是否提示"格式化程序未安装"或"有多个格式化程序"
第四步:ESLint 10 + TypeScript 类型感知实战 ------ 开启代码质量检查
引言
ESLint10 最大的变化是全面转向 Flat Config,废弃了沿用多年的 .eslintrc 体系。这不只是配置文件格式的调整,而是整个配置模型的重新设计:从"层层继承、隐式合并"变成"显式数组、按序生效"。对于还在用旧版配置的 Vue3 + TypeScript 项目来说,升级到 ESLint10 意味着要重新理解一套规则组织方式。
但 Flat Config 只是形式上的变化,真正决定 ESLint 能不能发现深层问题的,是有没有开启类型感知。
普通 ESLint 只看语法。它把代码解析成 AST,检查变量有没有声明、有没有用 var、有没有多余的转义字符。这些规则不需要知道任何类型信息,速度快,但能发现的问题也停留在表面。
类型感知(Type-Aware Linting)让 ESLint 拿到 TypeScript 的完整类型信息,从而发现那些语法上完全合法、只有类型系统才能暴露的问题。 最典型的是 no-floating-promises:
typescript
async function saveEmployee() {
return api.update(data)
}
function handleSubmit() {
saveEmployee() // ⚠️ 没有 await,Promise 悬空
}
saveEmployee() 返回的是 Promise<void>,调用时没有 await 也没有 .catch(),就是一个悬浮 Promise。用户以为保存成功了,实际上可能失败了。普通 ESLint 看不出任何问题,但类型感知会直接报错。
这就是为什么我们在项目里没有停在"配好 ESLint 能用"这一步,而是继续启用了 recommendedTypeChecked 和 projectService。前者让 ESLint 加载需要类型信息的规则集,后者让它复用 TypeScript 的 Project Service,自动为每个文件找到对应的 tsconfig ------ 不再手动维护一份 tsconfig.eslint.json。
接下来,我会从最小可运行配置开始,一步步加上全局变量、TS 支持、Vue 支持、导入检查和 Prettier,最后把类型感知作为压轴一步加进去。每一步都说明为什么加、加了之后解决什么问题。
第 1 步:安装 ESLint 并创建最小可运行配置
1.1 安装依赖
在项目根目录执行:
bash
pnpm add -D eslint @eslint/js jiti
这两个包分别是什么:
eslint:ESLint 本体,提供 CLI 和核心规则引擎。@eslint/js:ESLint 官方的 Javascript 规则集,导出configs.recommended和configs.all。拆成独立包是为了让核心eslint保持轻量,规则集可以单独更新。
为什么不用 npx eslint --init:
--init 会生成一套它认为合适的配置,但你不知道每一行是干什么的。手动从空文件开始,每一步都能讲清为什么。
1.2 创建配置文件
在项目根目录新建 eslint.config.mjs,写入:
javascript
// eslint.config.mjs
import eslint from '@eslint/js'
import { defineConfig } from 'eslint/config'
export default defineConfig([
eslint.configs.recommended,
])
逐行解释:
import eslint from '@eslint/js' 引入官方规则集包。它默认导出的是一个对象,里面有 configs 属性。
import { defineConfig } from 'eslint/config' 这是 ESLint 10 提供的类型辅助函数。它不影响运行时行为, 只是让你在编辑器里获得类型提示和自动补全。你可以不用它直接导出数组,但用了之后写配置时会有智能提示。
export default defineConfig([...]) ESLint 10 的 flat config 要求默认导出一个数组 。数组里每一项配置对象,ESLint 会按顺序合并它们。后面的配置可以覆盖前面的。
eslint.configs.recommended这是官方推荐规则集,包含了几十条基础规则,比如no-unused-vars、no-undef、no-debugger。它是纯语法层面的检查,不需要任何额外信息。
为什么文件后缀是 .mjs:
ESLint 10 支持 eslint.config.js、eslint.config.mjs、eslint.config.cjs。用 .mjs 是明确告诉 Node 这是一个 ES Module,可以用 import 语法。如果你的 package.json 里有 "type":"module",用 .js 也行,但 .mjs 更明确、不会出错。
1.3 验证
在 src 下随便写一个文件:
javascript
// src/test.js
const a = 1
运行:
bash
npx eslint src/test.js
预期结果: 报错 'a' is assigned a value but never used,来自 no-unused-vars 规则。说明 ESLint 跑起来了,推荐规则生效了。
如果没有任何输出,说明没检查到文件,检查路径对不对,或者文件是不被默认忽略了。
第 2 步:配置忽略目录
2.1 为什么需要忽略目录
ESLint 默认会扫描项目里所有 .js、.ts、.vue 等文件。但有些目录不应该被检查:
dist:构建产物,是编译后的代码,检查它没有意义,而且会拖慢速度。public:静态资源,通常不包含需要检查的源码。assets:图片、字体等资源目录,ESLint 不认识这些文件,扫描只会浪费时间。
如果不配置忽略,ESLint 会报一堆你根本不想管的错误,而且每次运行都要遍历这些目录。
2.2 修改配置文件
在 eslint.config.mjs 里,eslint.configs.recommended 后面加一项:
javascript
// eslint.config.mjs
import eslint from '@eslint/js'
import { defineConfig } from 'eslint/config'
export default defineConfig([
eslint.configs.recommended,
{ ignores: ['**/dist/**','public/**','**/assets/**'] }
])
2.3 逐行解释
{ ignores: [...] }
这就是一个普通的配置对象,但只包含ignores一个字段。
为什么必须单独放一个对象:
这是 flat config 的一个重要设计。ignores 有两种行为:
- 如果它出现在只包含
ignores的对象 里,它是全局忽略 ------ 所有配置都不作用于这些文件。 - 如果它和其他字段(比如
files、rules)出现在同一个对象里,它只对那个配置生效,不是全局忽略。
所以这里必须单独写一个对象,否则 dist 里的文件还是会被其他配置检查。
三个路径模式分别是什么:
'**/dist/**':匹配任意层级下的dist目录及其所有内容。**表示任意层级,/**表示目录下所有文件。'public/**':匹配根目录下public目录的所有内容。这里没用**/前缀,是因为public固定在根目录。**/assets/**:匹配任意层级下的assets目录。
为什么用 **/ 而不是直接写目录名:
因为 dist 可能出现在子包里(比如 monorepo 里每个包都有自己的 dist)。**/dits/** 能覆盖所有情况,不用一个一个列。
2.4 验证
在 dist 目录下随便建一个文件:
javascript
// dist/test.js
const unused = 123
运行:
javascript
npx eslint .
预期结果 :dist/test.js 里的 unused 不会 被报 no-unused-vars。如果报了,说明忽略配置没生效,检查 ignores 是不是单独放在一个对象里。
同时确认 src 下的文件仍然 被检查 ------ 比如 src/test.js 里的 const a = 1 还是应该报错。这样才能证明忽略只影响了目标目录,没有把整个项目都跳过。
第 3 步:自定义规则
3.1 为什么需要这一步
eslint.configs.recommended 是官方推荐规则集,但它只覆盖了最基础的几十条规则,而且所有规则一视同仁 ------ 开发和生产一样严格,浏览器和 Node 一样严格。
实际项目里你需要更细的控制:
- 开发时警告,生产时报错 :
console.log在开发时很常用,但生产环境不该留。 - 统一代码风格 :强制用
===而不是==,强制const而不是let。 - 禁用危险写法 :禁止在
return里写赋值语句,禁止用new String()这种包装对象。 - 允许约定的例外 :
_开头的参数不检查未使用,== null允许通过。
这些都需要你自己写规则。
3.2 修改配置文件
在 eslint.config.mjs 里,忽略目录后面加一个配置对象:
javascript
// eslint.config.mjs
import eslint from '@eslint/js'
import { defineConfig } from 'eslint/config'
export default defineConfig([
eslint.configs.recommended,
{ ignores: ['**/dist/**','public/**','**/assets/**'] },
{
rules:{
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-unused-vars': [
process.env.NODE_ENV === 'production' ? 'error' : 'warn',{ vars: 'all', args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-undef': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'error' :'off',
'accessor-pairs': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-label-var': 'error',
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
'no-lone-blocks': 'error',
'no-multi-str': 'error',
'no-new-wrappers': 'error',
'no-return-assign': ['error', 'except-parens'],
'no-self-compare': 'error',
'no-sequences': 'error',
'no-undef-init': 'error',
'no-unmodified-loop-condition': 'error',
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
'no-unreachable-loop': 'error',
'prefer-const': 'error',
'no-useless-escape': 'off',
}
}
])
3.3 逐段解释
环境变量控制严格度:
javascript
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-unused-vars': [
process.env.NODE_ENV === 'production' ? 'error' : 'warn',
{ vars: 'all', args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-undef': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
这四条规则都有一个共同逻辑:开发时宽松,生产时严格。
-
no-debugger:开发时忘了删debugger只是警告,生产环境直接报错,构建就失败。 -
no-unused-vars:开发时变量没用只是警告,生产环境报错。第二个参数时配置对象:vars: 'all':检查所有变量args: 'after-used':只检查最后一个使用参数之后的参数。比如function(a,b,c)里如果b没用但c用了,b不报错;如果c没用,c报错。1argsIgnorePattern:'^_',varsIgnorePattern: '^_':_开头的变量跳过检查。
-
no-undef:开发时警告,生产时报错。未声明的变量在生产环境是严重问题。 -
no-console:开发时完全关闭,允许随便用;生产环境报错,禁止留console.log。
代码风格规则:
javascript
'accessor-pairs': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-label-var': 'error',
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
'no-lone-blocks': 'error',
'no-multi-str': 'error',
'no-new-wrappers': 'error',
'no-return-assign': ['error', 'except-parens'],
'no-self-compare': 'error',
'no-sequences': 'error',
'no-undef-init': 'error',
'no-unmodified-loop-condition': 'error',
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
'no-unreachable-loop': 'error',
'prefer-const': 'error',
'no-useless-escape': 'off',
挑几个重点:
eqeqeq: ['error','always',{ null: 'ignore' }]:强制用===,但== null例外。因为== null同时匹配null和undefined,是有意为之的简写。no-return-assign:['error','except-parens']:禁止在return里赋值,但括号包来的例外。比如return (a = b)允许,return a = b报错。no-unneeded-ternary: ['error',{ defaultAssignment: false }]:禁止a ? a : b这种多余的三元,但a ? a : b里如果a是布尔值,a ? true : false这种也报错。prefer-const: 'error':声明后不再修改的变量必须用const。no-useless-escape: 'off':关掉不必要的转义字符检查。因为正则里有时需要写\/这种转义,虽然语法上不必要,但可读性更好。
为什么这些规则不区分环境:
它们都是代码质量问题,不是调试工具。开发和生产都应该遵守,所以统一 'error'。
3.4 验证
验证 no-console 的开发/生产差异:
在 src/test.js 里写:
javascript
// src/test.js
console.log('hello')
运行:
bash
npx eslint src/test.js
预期结果 :不报错,因为开发环境 no-console 是 off。
然后模拟生产环境:
bash
NODE_ENV=production npx eslint src/test.js
预期结果 :报 Unexpected console statement。
验证 prefer-const:
javascript
// src/test.js
let a = 1
console.log(a)
运行:
bash
npx eslint src/test.js
预期结果 :报 'a' is never reassigned. Use 'const' instead。
验证 eqeqeq 的例外:
javascript
// src/test.js
const a = null
if (a == null) console.log('ok') // 不报错
if (a == 1) console.log('bad') // 报错
预期结果 :第一行不报,第二行报 Expected '===' and instead saw '=='
验证 argsIgnorePattern:
javascript
function test(_unused,used) {
console.log(used)
}
test(1,2)
预期结果 :不报 _unused 未使用。
第 4 步:按文件类型区分浏览器和 Node 环境
4.1 这一步要解决什么问题
第 3 步你配了自定义规则,但有个问题没处理:ESLint 不知道你的代码跑在什么环境里。
看两个场景:
javascript
// src/utils/request.js
window.location.href = '/login' // 浏览器环境,window 合法
// vite.config.ts
process.env.NODE_ENV // Node 环境,process 合法
如果你不告诉 ESLint 哪个文件跑在哪个环境:
- 在
src下写window,ESLint 报'window' is not defined - 在
vite.config.ts里写process,ESLint 也报'process' is not defined
但这两个写法本身都是对的,只是跑在不同环境里。所以需要按文件路径区分环境。
4.2 安装依赖
bash
pnpm add -D globals
globals 包是什么:
纯数据包,导出各种运行环境的全局变量列表:
globals.browser:window、document、localStorage、fetch等globals.node:process、__dirname、Buffer、require等globals.es2024:Promise、Symbol、Reflect等
你之前发现 Promise 不报错,是因为 ecmaVersion: "latest" 已经覆盖了 ES 标准全局变量。但 window 和 process 不是 ECMAScript 标准的一部分 ,ecmaVersion 不会自动加。这就是 globals 包真正要解决的问题。
4.3 修改配置文件
在 eslint.config.mjs 里,第 3 步的自定义规则后面,加两个配置对象:
javascript
// eslint.config.mjs
import eslint from '@eslint/js'
import globals from 'globals'
import { defineConfig } from 'eslint/config'
export default defineConfig([
eslint.configs.recommended,
{ ignores: ['public/**','**/dist/**','**/assets/**'] },
// 第 3 步:自定规则
{
rules: {
// ... 第 3 步那堆规则
}
},
// 第 4 步:浏览器环境
{
files: ['src/**/*.{js,ts,tsx,vue}'],
languageOptions: {
globals:globals.browser,
}
},
// 第 4 步:Node环境
{
files:[
'**/*.config.{js,ts,mjs,cjs,mts,cts}',
'build/**/*.{js,ts,mjs,cjs,mts,cts}',
'eslint.config.mjs',
],
languageOptions: {
globals:globals.node,
},
rules: {
'no-console': 'off',
}
}
])
4.4 逐段解释
浏览器环境配置:
javascript
{
files:['src/**/*.{js,ts,tsx,vue}'],
languageOptions:{
globals:globals.browser,
}
}
files 限定这个配置只对 src 下的 .js、.ts、.tsx、.vue 文件生效。globals.browser 把 window、document、localStorage、fetch、setTimeout 等浏览器 API 加进合法全局变量列表。
为什么不用展开运算符:
第 3 步我教你写 ...globals.es2024,但这里写 globals.browser 没有展开。因为 flat config 的 globals 字段接受的是一个对象,globals.browser 本身就是对象,直接赋值即可。展开反而多余。
Node 环境配置:
javascript
{
files: [
'**/*.config.{js,ts,mjs,cjs,mts,cts}',
'build/**/*.{js,ts,mjs,cjs,mts,cts}',
'eslint.config.mjs',
],
languageOptions: {
globals: globals.node,
},
rules: {
'no-console': 'off',
},
},
files 匹配三类文件:
**/*.config.{js,ts,mjs,cjs,mts,cts}:所有配置文件,比如vite.config.ts、tailwind.config.js。build/**/*.{js,ts,mjs,cjs,mts,cts}:build目录下的构建脚本。eslint.config.mjs:ESLint 配置文件自己。你之前报的process is not defined就是因为它没被匹配到,加上这一条就解决了。
这些文件跑在 Node 里,需要 process、__dirname、Buffer 等全局变量。
为什么单独关掉 no-console:
构建脚本和配置文件里经常需要 console.log 输出构建信息、警告、错误。第 3 步你在生产环境把 no-console 设成了 'error',如果不再这里关掉,跑构建时 ESLint 会报错。
4.5 验证
验证浏览器环境:
javascript
// src/test.js
window.location.href = '/login'
document.title = 'test'
运行:
bash
npx eslint src/test.js
预期结果:不报错。
验证 Node 环境:
javascript
// vite.config.ts
console.log(process.env.NODE_ENV)
运行:
bash
npx eslint vite.config.ts
预期结果 :不报 'process' is not defined,也不报 no-console。
验证环境隔离:
javascript
// src/test.js
process.env.NODE_ENV
运行:
bash
npx eslint src/test.js
预期结果 :报 'process' is not defined。因为 src 下只加了 globals.browser,没有加 globals.node。这说明环境是精确隔离的。
验证 eslint.config.mjs 自己不再报错:
直接运行:
bash
npx eslint eslint.config.mjs
预期结果 :不报 'process' is not defined。因为你已经把 eslint.config.mjs 加进了 Node 环境的 files 里。
第 5 步:TypeScript 基础支持(先不开类型感知)
5.1 这一步要解决什么问题
前 4 步,ESLint 只检查了 JavaScript 语法。但你项目里写的是 .ts 和 .vue 文件,ESLint 默认不认识 TypeScript 的语法,比如:
typescript
const name: string = 'test'
function greet<T>(name: T): T { return name }
不加 TypeScript 支持,ESLint 会在 : string 和 <T> 这些地方直接解析失败,报 Parsing error。
typescript-eslint 这个包解决两件事:
- 提供 parser:让 ESLint 能解析 TypeScript 语法。
- 提供规则集 :
recommended、strict、recommendedTypeChecked等,包含大量 TS 专用规则。
这一步先用 recommended,只做语法层面的 TS 检查。recommendedTypeChecked 需要类型信息,留到第 8 步再加。
5.2 安装依赖
bash
pnpm add -D typescript-eslint
typescript-eslint 是什么:
它是 typescript-eslint 项目统一入口包,导出:
configs:规则集,比如configs.recommended、config.strict、configs.recommendedTypeCheckedparser:TypeScript 解析器plugin:规则插件
一个包解决所有问题,不需要单独装 @typescript-eslint/parser 和 @typescript-eslint/eslint-plugin。
5.3 修改配置文件
在 esllint.config.mjs 里,Node 环境配置后面加两块:
javascript
// eslint.config.mjs
import eslint from '@eslint/js';
import globals from 'globals';
import { configs as tseslintConfigs, parser as tseslintParser } from 'typescript-eslint';
import { defineConfig } from 'eslint/config';
export default defineConfig([
eslint.configs.recommended,
{ ignores: ['public/**', '**/dist/**', '**/assets/**'] },
// 第 3 步:自定义规则
{
rules: {
// ... 第 3 步那堆规则
},
},
// 第 4 步:浏览器环境
{
files: ['src/**/*.{js,ts,tsx,vue}'],
languageOptions: { globals: globals.browser },
},
// 第 4 步:Node 环境
{
files: [
'**/*.config.{js,ts,mjs,cjs,mts,cts}',
'build/**/*.{js,ts,mjs,cjs,mts,cts}',
'eslint.config.mjs',
],
languageOptions: { globals: globals.node },
rules: { 'no-console': 'off' },
},
// ⭐ 第 5 步:注入 TS 推荐规则集
...tseslintConfigs.recommended.map((config) => ({
...config,
files: ['**/*.{ts,tsx,mts,cts,vue}'],
})),
// ⭐ 第 5 步:TS 文件的 parser 和规则
{
files: ['**/*.?([cm])ts', '**/*.?([cm])tsx'],
languageOptions: {
parser: tseslintParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
warnOnUnsupportedTypeScriptVersion: false,
},
},
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/prefer-as-const': 'warn',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-wrapper-object-types': 'off',
'@typescript-eslint/no-namespace': 'off',
},
},
]);
5.4 逐段解释
引入 typescript-esllint:
javascript
import { configs as tseslintConfigs,parser as tseslintParser } from 'typescript-eslint';
configs重命名为tseslintConfigs,避免和eslint.configs混淆。parser重命名为tseslintParser,后面配置 parser 时要用。
注入 TS 规则集:
javascript
...tseslintConfigs.recommended.map((config) => ({
...config,
files: ['**/*.{ts,tsx,mts,cts,vue}'],
})),
为什么要 .map():
tseslintConfigs.recommended 是一个数组 ,里面每个配置对象默认作用于所有文件。如果不加 files 限制,这些 TS 规则会作用到 .js 文件上,但 .js 文件没有 TS 的类型语法,规则会误报。
map() 遍历数组,给每个配置对象加上 files 字段,只对 TS 和 Vue 文件生效。
...config 保留原配置的所有字段,只覆盖 files。
为什么这里用 recommended 而不是 recommendedTypeChecked:
recommended 只做语法层面的 TS 检查,不需要 tsconfig,速度快。
recommendedTypeChecked 需要类型信息,是第 9 步才加的。现在先用 recommended,把基础跑通。
TS 文件的 parser 配置:
javascript
{
files: ['**/*.?([cm])ts', '**/*.?([cm])tsx'],
languageOptions: {
parser: tseslintParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
warnOnUnsupportedTypeScriptVersion: false,
},
},
...
}
files 的正则 **/*.?([cm])ts 是匹配:
*.ts:普通 TS 文件*.mts:ES Module 格式的 TS*.cts:CommonJS 格式的 TS
?([cm]) 表示 c 或 m 可有可无。这是 typescript-eslint 官方推荐的写法。
parser: tseslintParser 让 ESLint 用 TypeScript 解析器解析这些文件。
parserOptions 里现在没有 projectService ------ 这是故意的,因为还没到类型感知那一步。 projectService 是第 9 步才加的。
为什么关掉 no-undef 和 no-unused-vars:
javascript
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [...],
no-undef在 TS 文件里必须关掉 。因为 TS 自己会检查未定义变量,而且 ESLint 的no-undef不认识 TS 的类型声明(type、interface),会误报。no-unused-vars换成@typescript-eslint/no-unused-vars。TS 版本的规则能正确处理 TS 特有的语法,比如类型导入、泛型参数。
为什么放宽一堆 TS 规则:
javascript
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/prefer-as-const': 'warn',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-wrapper-object-types': 'off',
'@typescript-eslint/no-namespace': 'off',
这些是 recommended 默认启用的规则,但实际项目里往往需要放宽:
ban-ts-comment:禁止@ts-ignore,但实际开发中有时必须用。no-explicit-any:禁止any,但对接后端接口时经常需要临时用。no-non-null-assertion:禁止!,但某些场景下比?.更合适。no-namespace:禁止namespace,但某些库的类型声明需要。
这些取舍不是"偷懒",而是实践中的权衡。面试时可以主动讲:哪些规则该严、哪些该松、为什么。
5.5 验证
验证 TS 语法被正确解析:
typescript
// src/test.ts
const name: string = 'test'
interface User {
id: number
name: string
}
const user: User = { id: 1, name }
console.log(user)
运行:
bash
npx eslint src/test.ts
预期结果 :不报解析错误。如果报 Parsing error,说明 parser 没配对。
验证 @typescript-eslint/no-unused-vars 生效:
typescript
// src/test.ts
const unused = 123
运行:
bash
npx eslint src/test.ts
预期结果: 报 'unused' is assigned a value but never used,来源是 @typescript-eslint/no-unused-vars。
验证 _ 开头的参数不报错:
typescript
scss
// src/test.ts
function test(_unused: number, used: number) {
console.log(used)
}
test(1, 2)
预期结果: 不报 _unused 未使用。
验证 any 不报错:
typescript
arduino
// src/test.ts
const data: any = { foo: 'bar' }
console.log(data)
预期结果: 不报 no-explicit-any。因为你在规则里把它关掉了。
第 6 步:Vue 文件支持
6.1 这一步要解决什么问题
第 5 步让 ESLint 能解析 .ts 文件了,但 .vue 文件还没处理。
.vue 文件和普通 .ts 文件不一样,它是一个容器,里面可能有三种东西:
vue
<template>
<div>{{ msg }}</div>
</template>
<script lang="ts" setup>
const msg = 'hello'
</script>
<style scoped>
div { color: red; }
</style>
ESLint 默认不认识 .vue 文件。如果不配 Vue 支持:
- 直接报
Parsing error,因为.vue不是合法的 JavaScript 语法。 - 即使勉强解析,
<template>里的指令(v-if、v-for)也会被当成错误语法。 <script lang="ts">里的 TS 语法也没人处理。
6.2 需要哪两个包,各干什么
bash
pnpm add -D eslint-plugin-vue vue-eslint-parser
vue-eslint-parser:
它是解析器 ,负责把 .vue 文件拆开:
- 从
<template>里提取模板内容。 - 从
<script>里提取脚本内容。 - 从
<style>里提取样式内容(ESLint 不管样式,这部分忽略)。
拆完之后,它把 <script> 里的内容交给另一个 parser 去解析。如果你的 <script lang="ts">,那就要把 tseslintParser 传给它。
eslint-plugin-vue:
它是规则插件,提供 Vue 专用规则,比如:
vue/multi-word-component-names:组件名必须是多个单词。vue/no-unused-vars:模板定义的变量必须被使用。vue/require-default-prop:props必须有默认值。vue/no-mutating-props:禁止直接修改props。
它同时提供一个 flat/recommended 配置集,开箱即用。
6.3 修改配置文件
在 eslint.config.mjs 里,TS 配置后加两块:
javascript
// eslint.config.mjs
import eslint from '@eslint/js';
import globals from 'globals';
import { configs as tseslintConfigs, parser as tseslintParser } from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';
import * as vueParser from 'vue-eslint-parser';
import { defineConfig } from 'eslint/config';
export default defineConfig([
eslint.configs.recommended,
{ ignores: ['public/**', '**/dist/**', '**/assets/**'] },
// 第 3 步:自定义规则
{ rules: { /* ... */ } },
// 第 4 步:浏览器环境
{
files: ['src/**/*.{js,ts,tsx,vue}'],
languageOptions: { globals: globals.browser },
},
// 第 4 步:Node 环境
{
files: [
'**/*.config.{js,ts,mjs,cjs,mts,cts}',
'build/**/*.{js,ts,mjs,cjs,mts,cts}',
'eslint.config.mjs',
],
languageOptions: { globals: globals.node },
rules: { 'no-console': 'off' },
},
// 第 5 步:TS 推荐规则
...tseslintConfigs.recommended.map((config) => ({
...config,
files: ['**/*.{ts,tsx,mts,cts,vue}'],
})),
// 第 5 步:TS parser
{
files: ['**/*.?([cm])ts', '**/*.?([cm])tsx'],
languageOptions: {
parser: tseslintParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
warnOnUnsupportedTypeScriptVersion: false,
},
},
rules: {
// ... 第 5 步那堆规则
},
},
// ⭐ 第 6 步:Vue 插件推荐规则
pluginVue.configs['flat/recommended'],
// ⭐ 第 6 步:Vue 文件的 parser 和规则
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: tseslintParser,
extraFileExtensions: ['.vue'],
sourceType: 'module',
},
},
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
},
},
]);
6.4 逐段解释
引入两个包:
javascript
import pluginVue from 'eslint-plugin-vue';
import * as vueParser from 'vue-eslint-parser';
eslint-plugin-vue 默认导出的是插件对象,可以直接访问它的 configs。
vue-eslint-parser 必须用 import * as 引入。因为它是 CommonJS 模块,导出的是一个命名空间对象,parser 在它的某个属性上。用 import * as 能拿到完整命名空间,这是社区通用的写法。
注入 Vue 推荐规则:
javascript
pluginVue.configs['flat/recommended'],
eslint-plugin-vue 提供了多套配置:
base:只包含解析相关的基础配置,不含规则。essential:只包含最重要的规则,避免错误。strongly-recommended:在 essential 基础上加更严格的规则。recommended:在 strongly-recommend 基础上再加更多规则,最常用。
这里的 flat/recommended 就是 flat config 版本的 recommended。它内部已经包含了 parser 配置,直接展开即可。
Vue 文件的 parser 配置:
javascript
{
files:['**/*.vue'],
parser: vueParser,
parserOptions: {
parser: tseslintParser,
extraFileExtensions: ['.vue'],
sourceType: 'module',
},
},
...
}
两层 parser 是重点:
- 外层
parser: vueParser:告诉 ESLint 用 vue-eslint-parser 来解析.vue文件。 - 内层
parserOptions.parser: tseslintParser:告诉 vue-eslint-parser,把<script lang="ts">里的内容交给 TS 解析器。
如果不加内层 parser,<script lang="ts"> 里的 const msg: string = 'hello' 会因为 TS 语法解析失败。
extraFileExtensions 是什么:
vue-eslint-parser 默认不处理 .vue 之外的文件类型。当你项目里还有其他自定义后缀(比如 .vue、.nvue)时,需要在这里声明。正常情况下 .vue 是必须加的。
为什么 Vue 文件也要关 no-unsafe-*:
javascript
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
这几个规则在纯 .ts 文件里非常有用,但在 .vue 文件的 <template> 里会大量误报。
原因:<template> 里的类型推断不如纯 TS 精确,比如 v-for 的 item 、props 的 xxx,在模板编译阶段类型容易变成 any,触发这些规则。社区普遍做法是在 Vue 文件里关掉这几个规则。
这是有意识的取舍 ,不是配置错误。面试可以主动讲:"我在纯 TS 文件里严格用 no-unsafe-*,但在 Vue 文件的模板部分关掉,因为模板的类型推断不如 TS 精确,关掉是为了避免噪音。"
6.5 验证
验证 .vue 文件能被解析:
新建 src/App.vue
vue
<template>
<div>{{ msg }}</div>
</template>
<script lang="ts" setup>
const msg: string = 'hello'
</script>
<style scoped>
div { color: red; }
</style>
运行:
bash
npx eslint src/App.vue
预期结果: 不报解析错误。如果报 Parsing error,检查两层 parser 是否都配了。
验证 Vue 规则生效:
vue
<template>
<div>test</div>
</template>
<script setup lang="ts">
// 组件名 foo(单词),会触发 vue/multi-word-component-names
</script>
保存文件名为 Foo.vue(单词),运行:
bash
npx eslint src/Foo.vue
预期结果: 报 vue/multi-word-component-names 相关错误。
验证 no-unused-vars 在 Vue 里用 TS 版本:
vue
<template>
<div>test</div>
</template>
<script setup lang="ts">
const unused = 123
</script>
运行:
bash
npx eslint src/App.vue
预期结果: 报 'unused' is assigned a value but never used,来源是 @typescript-eslint/no-unused-vars。
验证<script lang="ts">的 TS 语法被处理:
vue
<script setup lang="ts">
interface User { id: number }
const user: User = { id: 1 }
console.log(user)
</script>
运行:
bash
npx eslint src/App.vue
预期结果: 不报解析错误。说明内层 TS parser 生效了。
第 7 步:导入检查和 Prettier
7.1 这一步要解决什么问题
到第 6 步为止,ESLint 能检查语法、规则、Vue 文件,但还有两个问题:
问题一:导入路径没人管。
typescript
import { getUser } from '@/api/user' // 路径对不对?别名 @ 能不能解析?
import { foo } from './foo' // foo.ts 存在吗?
import { bar } from './bar' // 有没有循环依赖?
这些 ESLint 默认不管。你需要 eslint-plugin-import-x 来检查。
问题二:ESLint 和 Prettier 会打架。
Prettier 管格式(单引号、分号、换行),ESLint 也有格式规则(比如 quotes、semi)。两者同时开,会互相冲突:Prettier 把代码格式化成单引号,ESLint 报"必须用双引号"。
解决办法是 eslint-config-prettier,它会关掉所有和 Prettier 冲突的 ESLint 规则。
7.2 安装依赖
bash
pnpm add -D eslint-plugin-import-x eslint-import-resolver-typescript eslint-plugin-prettier eslint-config-prettier
四个包各干什么:
eslint-plugin-import-x:import/export语句的检查插件。-x是原eslint-plugin-import的维护分支,对 flat config 和 TS 支持更好。eslint-import-resolver-typescript:让import-x能解析 TS 的路径别名(@/这种)和.ts/.vue后缀。eslint-plugin-prettier:把 Prettier 当成 ESLint 规则跑。这样eslint --fix能同时修 ESLint 和 Prettier 的问题。eslint-config-prettier:关掉所有与 Prettier 冲突的 ESLint 规则。
7.3 修改配置文件
在 eslint.config.mjs 里,Vue 配置后面加:
javascript
// eslint.config.mjs
import eslint from '@eslint/js';
import globals from 'globals';
import { configs as tseslintConfigs, parser as tseslintParser } from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';
import * as vueParser from 'vue-eslint-parser';
import { importX } from 'eslint-plugin-import-x';
import eslintPluginPrettier from 'eslint-plugin-prettier/recommended';
import { defineConfig } from 'eslint/config';
export default defineConfig([
// ...第 1 到第 6 步的所有配置
// ⭐ 第 7 步:import-x 推荐规则
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
// ⭐ 第 7 步:import-x 的 resolver 配置
{
settings: {
'import-x/resolver': {
typescript: { projectService: true },
node: true,
},
'import-x/extensions': ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.vue'],
},
},
// ⭐ 第 7 步:允许配置文件里用 require
{
files: ['**/*.?([cm])js', '**/*.cts'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
// ⭐ 第 7 步:Prettier 必须放最后
eslintPluginPrettier,
]);
7.4 逐段解释
引入 import-x:
javascript
import { importX } from 'eslint-plugin-import-x';
注意这里是 { importX },不是默认导入。eslint-plugin-import-x 导出一个命名对象 importX,里面包含 flatConfigs。
注入 import-x 推荐规则:
javascript
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
flatConfigs.recommended:基础导入规则,比如no-unresolved(路径能不能解析)、no-duplicates(有没有重复导入)。flatConfigs.typescript:针对 TS 的补充规则,比如no-unresolved在 TS 项目里的正确行为。
这两个直接展开,不需要 .map() 加 files,因为它们内部已经处理好了。
resolver 配置:
javascript
{
settings: {
'import-x/resolver': {
typescript: { projectService: true },
node: true,
},
'import-x/extensions': ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.vue'],
},
},
typescript: { projectService: true }:
告诉 import-x 用 TypeScript 的 Project Service 来解析路径。这样 @/api/user 这种别名才能正确解析到 src/api/user.ts。
这里为什么可以提前用 projectService:
projectService 在这里不是给 ESLint 的类型感知用的,是给 import-x 的 resolver 用的。两个用途不同,不冲突。ESLint 自己的 projectService 是第 9 步才加。
node:true:同时启用 Node 的解析策略,处理 node_modules 里的包。
import-x/extensions:告诉 import-x 在解析导入时尝试这些后缀。如果你写 import App from './App',它会依次尝试 ./App.js、./App.ts、./App.vue 等。
为什么单独关掉 no-require-imports:
javascript
{
files: ['**/*.?([cm])js','**/*.cts']
rules: {
'@typescript-eslint/no-require-imports': 'off',
}
}
@typescript-eslint/no-require-imports 是第 5 步 recommended 里的规则,禁止用 require()。但配置文件(比如 .cjs)和某些 CommonJS 文件必须用 require。这里只针对 .js 和 .cts 关掉,其他文件(.ts、.mts)仍然禁止。
Prettier 放最后:
javascript
eslintPluginPrettier,
eslint-plugin-prettier/recommended 做了两件事:
- 把 Prettier 注册为 ESLint 规则,
eslint --fix时能格式化。 - 通过
eslint-config-prettier关掉所有与 Prettier 冲突的 ESLint 规则。
为什么必须放最后:
因为它是"关闭冲突规则"的配置。如果放在前面,后面第5、6步的规则会覆盖它,冲突又回来了,放在最后,确保覆盖前面所有配置。
7.5 验证
验证路径别名解析:
前提:你的 tsconfig.json 里配了 @/* 别名,比如:
json
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
在 src/test.ts 里写:
typescript
import { something } from '@/api/user'
console.log(something)
运行:
bash
npx eslint src/test.ts
预期结果: 不报 Unable to resolve path to module。
验证路径不存在时报错:
typescript
import { something } from '@/api/not-exist'
console.log(something)
运行:
bash
npx eslint src/test.ts
预期结果: 报 Unable to resolve path to module '@/api/not-exist'。
验证 Prettier 生效:
写一段格式很乱的代码:
typescript
const a=1;const b =2
console.log(a,b)
运行:
bash
npx eslint src/test.ts --fix
预期结果: 文件被格式化成:
typescript
const a = 1
const b = 2
console.log(a, b)
(具体格式取决于你的 .prettierrc。) 验证 Prettier 冲突规则被关掉:
随便写个双引号字符串:
typescript
const str = "hello"
console.log(str)
运行:
bash
npx eslint src/test.ts
预期结果: 不报 quotes 相关错误。因为 eslint-config-prettier 把这条规则关了。
7.6 一个容易踩的坑
如果你运行 npx eslint src/test.ts --fix 后,Prettier 报错说"找不到配置文件",说明你没建 .prettierrc。ESLint 里的 Prettier 规则会读取项目根的 Prettier 配置。
先创建 .prettierrc:
json
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}
这个文件是 Prettier 自己的配置,和 ESLint 独立。ESLint 通过 eslint-plugin-prettier 调用 Prettier 时会读取它。
第 8 步:类型感知(压轴)
8.1 这一步要解决什么问题
前 7 步的 ESLint 只看语法 。它能把代码解析成 AST,检查变量有没有声明、有没有用 var、导入路径对不对。但有一类 Bug 语法上完全合法,只有类型信息才能发现。
最典型的是 no-floating-promises:
typescript
async function saveEmployee() {
return api.update(data)
}
function handleSubmit() {
saveEmployee() // ⚠️ 没有 await,Promise 悬空
}
saveEmployee() 返回 Promise<void>,调用时没 await 也没 .catch(),就是一个悬浮 Promise。用户以为保存成功了,实际可能失败了。普通 ESLint 看不出任何问题,但类型感知会直接报错。
类似的高价值规则还有:
no-misused-promises:在if里误用 async 函数。await-thenable:对非 Promise 用await。no-unnecessary-type-assertion:多余的类型断言。restrict-template-expressions:模板字符串用了非字符串类型。
这些规则只有拿到 TypeScript 的完整类型信息才能跑。
8.2 核心概念:projectService 是什么
类型感知的关键,是让 ESLint 的解析器知道每个源文件属于哪个 tsconfig,从而拿到完整类型信息。
**旧方案(parserOptions.project):
javascript
parserOptions: {
project: './tsconfig.json'
}
要求手动指定 tsconfig 路径。在 monorepo 或多 tsconfig 项目里极其痛苦,经常要单独维护一份 tsconfig.eslint.json,把 ESLint 要检查的所有文件都 include 进去。
**新方案(parserOptions.projectService: true):
这是 TypeScript-eslint v8 稳定的 API。它直接调用 TypeScript 的 Project Service ------ 也就是 VS Code 编辑器内部用的同一套服务 ------ 自动为每个文件找到对应的 tsconfig,不再需要 ESLint 专用的 tsconfig。
| 对比项 | 旧 project |
新 projectService |
|---|---|---|
| 配置复杂度 | 高,需手动指定路径 | 低,自动检测 |
| monorepo 支持 | 差 | 好,支持 project references |
| 与编辑器一致性 | 低 | 高,同一套服务 |
| 类型信息准确性 | 取决于手动配置 | 和 VS Code 一致 |
8.3 修改配置文件
类型感知需要改两个地方。
改动一:把recommended换成recommendedTypeChecked
第 5 步你写的是:
javascript
...tseslintConfigs.recommended.map((config) => ({
...config,
files: ['**/*.{ts,tsx,mts,cts,vue}'],
}))
改成:
javascript
...tseslintConfigs.recommendedTypeChecked.map((config) => ({
...config,
files: ['**/*.{ts,tsx,mts,cts,vue}'],
})),
recommended 和 recommendedTypeChecked 的区别:
recommended:纯语法规则,不需要类型信息,速度快。recommendedTypeChecked:包含recommended的所有规则, 再加上需要类型信息的规则。它需要parserOptions.projectService才能工作。
改动二:在 TS 和 Vue 的 parserOptions 里加 projectService
第 5 步 TS 文件的配置:
javascript
{
files:['**/*.?([cm])ts','**/*.?([cm])tsx'],
languageOptions: {
parser:tseslintParser,
parserOptions: {
projectService: true, // 新增
ecmaVersion: 'latest',
sourceType: 'module',
warnOnUnsupportedTypeScriptVersion: false,
},
},
rules: {
// ... 第 5 步那堆规则
}
}
第 6 步 Vue 文件的配置:
javascript
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
projectService: true, // 新增
extraFileExtensions: ['.vue'],
sourceType: 'module',
},
},
rules: {
// ... 第 6 步那堆规则
},
},
注意:projectService 必须同时加在 TS 和 Vue 两处 。因为 TS 文件和 Vue 文件是分开配置的,各自需要自己的 parserOptions。
8.4 完整配置
把第 5、6 步的配置合并后,最终是这样:
javascript
// eslint.config.mjs
import eslint from '@eslint/js';
import globals from 'globals';
import { configs as tseslintConfigs, parser as tseslintParser } from 'typescript-eslint';
import pluginVue from 'eslint-plugin-vue';
import * as vueParser from 'vue-eslint-parser';
import { importX } from 'eslint-plugin-import-x';
import eslintPluginPrettier from 'eslint-plugin-prettier/recommended';
import { defineConfig } from 'eslint/config';
export default defineConfig([
eslint.configs.recommended,
{ ignores: ['public/**', '**/dist/**', '**/assets/**'] },
// 第 3 步:自定义规则
{
rules: {
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-unused-vars': [
process.env.NODE_ENV === 'production' ? 'error' : 'warn',
{ vars: 'all', args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-undef': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'accessor-pairs': 'error',
eqeqeq: ['error', 'always', { null: 'ignore' }],
'no-label-var': 'error',
'no-labels': ['error', { allowLoop: false, allowSwitch: false }],
'no-lone-blocks': 'error',
'no-multi-str': 'error',
'no-new-wrappers': 'error',
'no-return-assign': ['error', 'except-parens'],
'no-self-compare': 'error',
'no-sequences': 'error',
'no-undef-init': 'error',
'no-unmodified-loop-condition': 'error',
'no-unneeded-ternary': ['error', { defaultAssignment: false }],
'no-unreachable-loop': 'error',
'prefer-const': 'error',
'no-useless-escape': 'off',
},
},
// 第 4 步:浏览器环境
{
files: ['src/**/*.{js,ts,tsx,vue}'],
languageOptions: { globals: globals.browser },
},
// 第 4 步:Node 环境
{
files: [
'**/*.config.{js,ts,mjs,cjs,mts,cts}',
'build/**/*.{js,ts,mjs,cjs,mts,cts}',
'eslint.config.mjs',
],
languageOptions: { globals: globals.node },
rules: { 'no-console': 'off' },
},
// ⭐ 第 8 步:类型感知规则集
...tseslintConfigs.recommendedTypeChecked.map((config) => ({
...config,
files: ['**/*.{ts,tsx,mts,cts,vue}'],
})),
// 第 6 步:Vue 插件推荐规则
pluginVue.configs['flat/recommended'],
// ⭐ 第 8 步:Vue 文件 + 类型感知
{
files: ['**/*.vue'],
languageOptions: {
parser: vueParser,
parserOptions: {
projectService: true,
parser: tseslintParser,
extraFileExtensions: ['.vue'],
sourceType: 'module',
},
},
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
},
},
// ⭐ 第 8 步:TS 文件 + 类型感知
{
files: ['**/*.?([cm])ts', '**/*.?([cm])tsx'],
languageOptions: {
parser: tseslintParser,
parserOptions: {
projectService: true,
ecmaVersion: 'latest',
sourceType: 'module',
warnOnUnsupportedTypeScriptVersion: false,
},
},
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/prefer-as-const': 'warn',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
'@typescript-eslint/no-wrapper-object-types': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
// 第 7 步:import-x
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
{
settings: {
'import-x/resolver': {
typescript: { projectService: true },
node: true,
},
'import-x/extensions': ['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.tsx', '.vue'],
},
},
{
files: ['**/*.?([cm])js', '**/*.cts'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
// 第 7 步:Prettier 放最后
eslintPluginPrettier,
]);
8.5 验证类型感知是否生效
**验证 no-floating-promises:
typescript
// src/test.ts
async function saveEmployee() {
return Promise.resolve('saved')
}
function handleSubmit() {
saveEmployee() //这里应该报 no-floating-promises
}
handleSubmit()
运行:
bash
npx eslint src/test.ts
预期结果:报
test
Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the void operator
**验证 no-misused-promises:
typescript
// src/test.ts
async function check(): Promise<boolean> {
return true
}
if (check()) { // 这里应该报 no-misused-promises
console.log('ok')
}
运行:
bash
npx eslint src/test.ts
预期结果 :报 Expected a non-Promise value
**验证 await-thenable:
typescript
// src/test.ts
async function test() {
const num = 123
await num // 这里应该报 await-thenable
}
test()
运行:
bash
npx eslint src/test.ts
预期结果 :报 Unexpected await of a non-Promise value。
验证 Vue 文件里类型感知也生效:
vue
<template>
<div>{{ msg }}</div>
</template>
<script setup lang='ts'>
async function load() {
return Promise.resolve('data')
}
function init() {
load() // 应该报 no-floating-promises
}
init()
</script>
运行:
bash
npx eslint src/App.vue
预期结果 :报 no-floating-promises。
8.6 常见问题排查
**问题一:报 Parsing error: Cannot read file 'tsconfig.json'
说明 projectService 找不到 tsconfig。检查:
- 项目根目录有没有
tsconfig.json tsconfig.json的include是否包含你在检查的文件。- 如果是 monorepo,每个包都要自己的
tsconfig.json。
问题二:类型感知规则不报
说明 ESLint 没拿到类型信息。检查:
recommendedTypeChecked有没有被正确.map()parserOptions.projectService有没有加在 TS 和 Vue 两处。- 运行
npx eslint --debug src/test.ts看解析器有没有加载 tsconfig。
问题三:Lint 变得非常慢
类型感知需要 TypeScript 分析整个项目,比纯语法检查慢很多。这是它的核心权衡。优化策略:
- 只在核心业务代码上开类型感知,测试、配置文件用
disableTypeChecked关掉。 - CI 里单独跑类型感知任务,开发时用轻量 Lint 保证速度
- 用
--cache缓存检查结果,只检查改动的文件。
8.7 面试话术
把这个配置讲成一个有决策、有取舍的工程判断:
"我的项目用 TypeScript 严格模式,但 ESLint 默认只做语法检查。有些异步 Bug ------ 比如悬浮 Promise ------ 语法上完全合法,只有类型系统才能发现。所以我启用了 typescript-eslint 的 type-aware linting。
配置上我用了 v8 新的
parserOptions.projectService,而不是旧的project字段。因为 projectService 直接复用 TypeScript 的 Project Service,和 VS Code 编辑器用的是同一套服务,在 monorepo 或多 tsconfig 场景下不用再维护 ESLint 专用的 tsconfig,配置更简单,和编辑器的行为也一致。代价是类型感知 Lint 会慢一些,因为需要分析整个项目。我的处理是只在核心业务代码上开启,对测试和配置文件用
disableTypeChecked关掉,CI 里单独跑类型感知任务,保证开发时的反馈速度。"