手把手基于ESLint 9+Husky+Prettier+Commitlint+Lint-staged配置前端规范
新人进组第二天提了个 PR,diff 里三百多行,真正改的业务逻辑只有五行,剩下全是缩进、引号、分号的差异。review 的人翻了十分钟没看出他到底做了什么,最后只能让他 revert 重来。这种事在没有统一规范的仓库里几乎每两周就要上演一次。
这篇把我在一个 Next.js 16 + TypeScript 项目里实际用的那套规范链路完整拆开写,ESLint 9 的扁平化配置怎么组、Prettier 和 ESLint 的边界划在哪、Lint-staged 为什么只检查暂存文件、Husky 9 的钩子该怎么写才不会在半年后失效。读完你能直接照着搭一份跑得起来的配置,也知道每个开关是为什么打开的。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 这套工具链里每个工具各自负责哪一段,边界划错了会怎样
- Next.js 16 项目的依赖安装清单与版本约束
- ESLint 9 扁平化配置(Flat Config)的完整写法与规则取舍
- Prettier、EditorConfig、VSCode 三者如何配合做到保存即修复
- Commitlint 约束提交信息、Lint-staged 做增量检查
- Husky 9 把钩子串起来,以及 husky add 已经不能用了这件事
- 一次完整提交在这条链路上都发生了什么
- 我自己踩过的几个坑和排查思路
# 一、先分清楚这几个工具各管什么
搭之前有个问题得想明白,为什么一套规范要装这么多包,一个 ESLint 不够吗。
不够。ESLint 管的是代码质量,比如变量声明了没用、Hook 写在条件分支里、img 标签缺 alt 属性,这些是「写错了」的问题。Prettier 管的是代码风格,单引号还是双引号、一行放多少个字符、对象末尾要不要留逗号,这些是「长得不一样」的问题。两件事混在一个工具里做,结果就是规则互相打架,改一个报另一个。
分工大概是这样:
| 工具 | 负责什么 | 配置文件 |
|---|---|---|
| ESLint 9 | 代码质量与潜在 bug | eslint.config.mjs |
| Prettier | 代码风格与排版 | .prettierrc.js |
| EditorConfig | 编辑器层面的缩进换行 | .editorconfig |
| Commitlint | 提交信息格式 | commitlint.config.js |
| Lint-staged | 只检查暂存区文件 | lint-staged.config.js |
| Husky | 在 Git 生命周期上挂钩子 | .husky/ |
后面三个是「什么时候执行」的问题,前面三个是「执行什么」的问题。理清这条线,配置文件之间的引用关系就顺了。
顺着上面聊,EditorConfig 看起来和 Prettier 重复,其实不是。Prettier 只在你保存或者跑命令时才动手,EditorConfig 是编辑器打开文件那一刻就生效的,管的是你敲 Tab 键出来几个空格。团队里有人用 WebStorm 有人用 VSCode 的时候,这个文件能省掉不少扯皮。
# 二、项目初始化与依赖安装
先起一个 Next.js 16 项目。
npx create-next-app@latest my-app --typescript --tailwind --eslint
cd my-app
--eslint 会带一份最基础的 eslint.config.mjs,我们后面会整个覆盖掉,但留着它可以省去手动装 eslint-config-next 的步骤。
接下来装 ESLint 9 这一组。@eslint/js 提供官方推荐规则集,typescript-eslint 是新的统一入口包,替代了以前要同时装 @typescript-eslint/parser 和 @typescript-eslint/eslint-plugin 的写法。
npm install eslint@9 @eslint/js@9 --save-dev
npm install typescript-eslint@8 --save-dev
npm install eslint-config-next@16 --save-dev
npm install eslint-plugin-simple-import-sort@12 eslint-plugin-unused-imports@4 --save-dev
npm install @eslint/compat@1 --save-dev
@eslint/compat 这个包很关键。ESLint 9 对插件的写法做了破坏性调整,社区里有不少插件当时还没跟上,fixupPluginRules 能把老写法的插件包一层适配到扁平化配置里。关于 ESLint 8 到 9 到底改了哪些东西,我之前写过一篇更细的拆解,可以配合看 ESLint 9 完全指南。
然后是 Prettier 这一组。
npm install prettier@3 prettier-plugin-tailwindcss@0.7 --save-dev
npm install eslint-config-prettier@10 --save-dev
eslint-config-prettier 不加任何规则,它只做一件事,把 ESLint 里所有和排版相关、会跟 Prettier 打架的规则全部关掉。装了它,上面说的「规则互相打架」就基本消失了。
最后是 Git 钩子这一组。
npm install husky@9 lint-staged@16 --save-dev
npm install @commitlint/cli@17 @commitlint/config-conventional@17 --save-dev
装完对一遍完整的清单,版本号锁在这个区间是我实际验证过能跑通的组合。
{
"devDependencies": {
"@commitlint/cli": "^17.7.2",
"@commitlint/config-conventional": "^17.7.0",
"@eslint/compat": "^1.4.1",
"@eslint/eslintrc": "^3",
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.3.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
"typescript": "^5.9.3"
}
}
这里有个细节容易被忽略,@eslint/eslintrc 和 @eslint/compat 是两个不同的东西。前者用来把老的 .eslintrc 配置塞进扁平化配置(FlatCompat),后者用来适配老写法的插件。如果你是全新项目,@eslint/eslintrc 其实可以不装。
# 三、ESLint 9 扁平化配置怎么写
ESLint 9 把配置格式换成了 Flat Config,文件名从 .eslintrc.json 变成 eslint.config.mjs,结构从一个大对象变成了一个数组。数组里每一项是一段独立配置,按顺序合并,后面的覆盖前面的。
先看骨架部分。
// eslint.config.mjs
import { defineConfig, globalIgnores } from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
import nextTs from 'eslint-config-next/typescript'
import tseslint from 'typescript-eslint'
import simpleImportSort from 'eslint-plugin-simple-import-sort'
import unusedImports from 'eslint-plugin-unused-imports'
import { fixupPluginRules } from '@eslint/compat'
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
tseslint.configs.recommendedTypeChecked,
// ...下面是我们自己的那一段
])
export default eslintConfig
eslint-config-next 在 16 版本里拆成了 core-web-vitals 和 typescript 两个子路径导出,直接展开进数组就行,不再需要以前 extends: 'next/core-web-vitals' 那种字符串写法。这也是 Flat Config 最舒服的一点,配置就是普通的 JS 模块,能 import 能展开能条件判断。
tseslint.configs.recommendedTypeChecked 是带类型信息的规则集,它比 recommended 强得多,能查出 await 一个非 Promise 这类问题,代价是要求 ESLint 能读到 TS 的类型信息,也就是下面 projectService: true 那行。
{
files: ['src/**/*.{js,jsx,ts,tsx}'],
plugins: {
'simple-import-sort': fixupPluginRules(simpleImportSort),
'unused-imports': fixupPluginRules(unusedImports)
},
extends: [],
languageOptions: {
parserOptions: {
projectService: true
},
globals: {
JSX: true
}
},
rules: {
// 见下一段
}
}
projectService: true 是 typescript-eslint 8 推荐的新写法,替代了以前手写 project: './tsconfig.json' 的方式,它会自动找到每个文件对应的 tsconfig,monorepo 里省事很多。这里有个坑要注意,一旦开了类型检查规则,ESLint 的执行时间会明显变长,因为它要跑一遍 TS 的类型推导。项目大了之后 npx eslint src 跑个几十秒是正常的,所以后面才需要 Lint-staged 只检查改动文件。
files 限定成 src/** 也是有意的。构建产物、配置文件、脚本目录不该被带类型的规则扫,不然会因为它们不在 tsconfig 的 include 范围里直接报错。
规则那一块比较长,我按用途拆成三组说。第一组是把默认开着但和项目习惯冲突的规则关掉。