1. 为什么偏要在 commit 之前加一道拦截门
先说个特别常见的尴尬场景:本地写完代码,git commit的时候也没做检查,推到远端后 CI 开始跑 lint 和类型检查,结果红灯亮了。你看着那一长串报错,心里其实很清楚——这个问题在本地两三秒就能发现,完全不该由 CI 来背这个锅。
我做 React 项目这些年,最怕的不是需求复杂,而是代码质量在“提交之后”才暴露问题。尤其是团队协作的时候,每个人的编辑器设置不一样,有人配了保存自动格式化,有人没有;有人习惯写完了手动跑一遍 ESLint,有人到了提测前才发现一堆 warning。这些差异最后都会集中在 commit 历史里,变成 review 时毫无意义的大量 diff。
这篇文章要解决的,就是用 Git Hooks 在提交前设置一道自动校验与格式化的闸门。具体到技术栈,是 Vite + React 19 项目,工具链采用 husky + lint-staged + ESLint + Prettier。核心效果是:你只要执行git commit,暂存区里的代码会先被自动格式化,再被 ESLint 检查,只要有任何 lint 错误或格式问题,提交就会直接失败,错误信息直接打在终端里。
有人可能会说,npm run lint也能做这件事,何必多装一堆工具?区别在于:手动检查靠自觉,自动拦截靠机制。后者不会被任何人遗忘,也不会因为某个同事今天状态不好而漏掉。
这套方案适合谁?适合正在从“一个人写代码”转向“多人协作维护”的团队,也适合任何一个想把自己的 commit 历史收拾得干净利索的个人开发者。它不挑项目大小,React 19、React 18,甚至 Vue 项目都能套用,只是今天所有示例都放在 Vite + React 19 的场景里展开。
1.1 从“CI 亮红灯”到“本地拦下来”:成本差在哪
很多团队把 lint 和格式化放在 CI 里做,理由是“反正机器会自动跑”。但这里有一个非常容易被忽略的成本问题:反馈链路太长。
在 CI 上发现一个格式问题,意味着你已经完成了编码、提交、推送、等待流水线执行这一整套流程。如果这个项目构建速度还慢一点,一次提交可能要等十分钟以上才知道代码过不过。而如果检查发生在本地,在你敲下git commit的那一瞬间,问题就被拦住了。改完重新 add、重新 commit,整个过程不会超过半分钟。
另一个容易忽略的问题是“污染历史”。如果 CI 过了但代码格式很乱,这些乱格式就已经进了 commit 历史。后续做git log、git blame的时候,看到的会是大量格式变更混在功能变更里,排查问题的时候非常痛苦。提交前自动格式化,能把这个污染源直接掐掉。
1.2 Git hooks 的生效时机,以及 pre-commit 为什么最常用
Git Hooks 不是什么新概念,它就是 Git 在执行特定动作时触发的脚本。常见的钩子有pre-commit(提交前)、prepare-commit-msg(编辑提交信息前)、commit-msg(提交信息生成后)、pre-push(推送前)等。
对我们这个场景而言,pre-commit是最合适的一道闸门。因为代码从“本地工作区”进入“版本历史”的最后一关就是 commit,在这之前拦截,能保证所有进入 Git 历史的内容都是被检查过的。
当然,如果你想做到更严格,也可以在pre-push里再跑一次全量检查,防止某些绕过 commit 检查的情况出现。但作为日常开发,pre-commit是最直接、反馈最快的选择。
1.3 这套方案在 Vite + React 19 项目里的特殊注意点
Vite 官方脚手架生成的 React 模板,默认已经帮你配好了 ESLint。但这里有个容易踩坑的地方:Vite 官方模板用的是 ESLint 9 的 flat config 体系,配置文件名不再是.eslintrc.cjs或.eslintrc.json,而是eslint.config.js。很多从旧项目迁移过来的同学,第一反应是去找.eslintrc,结果发现根本不存在,一脸蒙。
React 19 也有一个特殊点需要留意:新版本默认使用新的 JSX transform,组件文件里不需要再import React from 'react'。这意味着如果你从旧项目里拷贝了react/react-in-jsx-scope这条规则过来,会直接报错。好在 Vite 官方模板没这个问题,但自己手动搭过配置的人就要警惕。
2. 工具链分工:husky、lint-staged、ESLint、Prettier 各管哪一段
很多人第一次接触这套组合时,最困惑的是工具之间的边界。到底谁负责拦截提交?谁负责格式化?谁负责检查规范?其实分清楚之后,一切都很好理解。
2.1 四件套的职责清单
我直接用一张表把这几个工具的分工理清楚:
| 工具 | 职责 | 一句话解释 |
|---|---|---|
| husky | 管理 Git Hooks | 负责把pre-commit这类钩子脚本挂到 Git 上,并让团队共享同一套配置 |
| lint-staged | 筛选暂存区文件 | 只对git add过的文件执行命令,避免每次提交把整个项目 lint 一遍 |
| ESLint | 静态代码检查 | 检查 React/TypeScript 代码里是否存在错误、未使用变量、Hooks 依赖等问题 |
| Prettier | 代码格式化 | 统一引号、分号、缩进、换行等风格,让所有人的代码长成一个样 |
这套组合的逻辑是:husky 负责触发,lint-staged 负责挑选文件,Prettier 负责把格式改对,ESLint 负责揪出真正的代码问题。四者不是竞争关系,而是流水线关系。
2.2 为什么不建议直接手工维护 .git/hooks/pre-commit
有人会问:Git 本身就有.git/hooks/pre-commit这个东西,直接在目录里放一个脚本不就行了?理论上是可行的,但有几个致命问题。
第一,.git目录不会跟着仓库提交,所以这个钩子脚本只在你自己机器上生效,换一台电脑或换一个协作者就没了。第二,初始化脚本非常繁琐。当你git clone一个新的仓库下来,还得手动去.git/hooks里修改配置,这对团队协作来说是灾难。
husky 的价值就在这里:它提供了prepare脚本机制,在每次npm install的时候自动把钩子目录挂好。也就是说,同事拉取代码后只要安装一次依赖,提交前校验就会自动生效,不需要任何额外的手动配置。
2.3 版本边界:ESLint 9、Husky v9、lint-staged v15
写这篇内容的时候,我建议你用这些版本,它们彼此配合更稳:
- husky: v9.x
- lint-staged: v15.x
- eslint: v9.x
- prettier: v3.x
- typescript-eslint: v8.x
- eslint-plugin-react-hooks: v5.x
- eslint-config-prettier: v10.x
这里要特别注意 husky。v9 版本的初始化方式和 v4/v8 完全不同,网上很多教程还在教npx husky install这套老流程,实际上在 v9 里已经被淘汰了。如果你照抄老教程,大概率会遇到“钩子文件生成了但就是不生效”的诡异问题。
2.4 pnpm 用户先看这里
如果你不是 npm 而是 pnpm 用户,需要在第 4 节的操作里额外留意一个点:pnpm 默认会拦截依赖包的 postinstall 脚本,而 husky 恰恰是用prepare脚本来完成初始化的。
如果你在安装 husky 后发现钩子根本没装上,第一件事就是去项目根目录跑一下pnpm rebuild husky,或者直接手动执行一下prepare脚本。这个问题非常隐蔽,报错也很少,但它真实存在于日常使用中。
3. 先把“地基”打好:Vite + React 19 里的 ESLint 与 Prettier 配置
在接 Git Hooks 之前,我习惯先把 ESLint 和 Prettier 单独跑通。原因很简单:如果命令本身就没配好,放进 hook 里也只会得到一堆莫名其妙的报错,排查起来更难。
3.1 从空项目到 ESLint 9 flat config 正常跑起来
创建一个 Vite + React 19 + TypeScript 项目:
npm create vite@latest react19-hooks-demo -- --template react-ts cd react19-hooks-demo npm install npm run lint刚初始化完的项目,Vite 模板已经生成了eslint.config.js,并且默认配好了eslint-plugin-react-hooks和eslint-plugin-react-refresh。这里的npm run lint实际执行的是eslint .,全项目扫描。
如果你用的是官方模板,这一步通常能直接通过。但如果你是从老项目迁移过来的,需要手动建立 flat config,我给你一个可以直接用的版本:
import js from '@eslint/js' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import prettier from 'eslint-config-prettier' export default tseslint.config( { ignores: ['dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, prettier )注意最后一行那个prettier。这不是 Prettier 插件,而是eslint-config-prettier,它的作用是关掉 ESLint 中与 Prettier 格式规则冲突的那些规则。很多教程忽略这一步,结果 ESLint 和 Prettier 在同一段代码上打架,一个要分号一个不要分号,勾子永远过不去。
3.2 给 Prettier 一个明确且可执行的格式化基线
Prettier 的默认配置已经足够合理,但仍建议在项目根目录放一个.prettierrc文件,把团队约定固化下来:
{ "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "es5", "endOfLine": "auto" }我个人的偏好是无分号、单引号、每行 100 字符。这不是标准答案,重要的是团队统一。配置里最后那个endOfLine: "auto"建议保留,它可以减少 Windows 和 macOS 之间因为换行符导致的文件被大量改动的问题。
然后安装并验证:
npm install -D prettier eslint-config-prettier npx prettier --check src/如果输出一堆文件名,说明文件没有格式化;执行npx prettier --write src/就能自动格式化。后面接入 lint-staged 时,我们会在提交时自动执行这个动作。
3.3 React 19 和 ESLint 配置之间的两个隐藏关系
第一个隐藏关系是 JSX 转换。React 19 使用新的 JSX transform,组件文件不用再引入 React。如果是从老项目迁移,记得不要在 ESLint 中开启react/react-in-jsx-scope,否则每个组件文件都会报'React' is not defined或者需要你引入一个根本用不到的依赖。
第二个隐藏关系是 Hooks 插件版本。React 19 的eslint-plugin-react-hooks版本已经到 v5,如果你还是 v4,某些新规则可能无法识别,尤其是针对函数组件和自定义 Hook 的依赖检查。建议直接安装最新版:
npm install -D eslint-plugin-react-hooks@latest4. 接入 husky 与 lint-staged:真正实现提交前自动校验和格式化
这一步是整个方案落地的核心。前面所有的配置,到这一步都会被串起来。
4.1 安装 husky:一条 init 命令背后的机制
安装 husky 分两步:
npm install -D husky npx husky init执行npx husky init之后,husky 会在项目根目录生成一个.husky文件夹,里面默认包含一个pre-commit文件。同时在package.json里加上这样一段脚本:
"scripts": { "prepare": "husky" }这里的prepare是 npm 的生命周期脚本。每次项目执行npm install的时候,npm 都会自动运行它,husky 会用这个时机把 Git hooks 指向.husky目录。这也是为什么前面说“同事拉完代码装一次依赖,钩子就自动生效了”。
husky 默认生成的.husky/pre-commit内容一般是这样的:
npx lint-staged如果只想先测试 hooks 机制,可以先把这一行改成echo "hook working",提交一次,看到终端打印出这句话,说明钩子已经生效,再改回来。
另外,可以用这条命令确认 hooks 路径确实被改过:
git config --get core.hooksPath正常会输出.husky/_,这是 husky 在内部管理脚本用的目录。
4.2 写准 lint-staged 配置:让 lint 和 format 有序执行
在项目根目录新建lint-staged.config.js:
export default { '*.{ts,tsx,js,jsx}': ['eslint --fix', 'prettier --write'], '*.{json,css,md,html}': ['prettier --write'], }这个配置的含义是:当暂存区里有 TypeScript/JavaScript 文件时,先执行eslint --fix,把能自动修复的问题修掉;然后执行prettier --write,把格式统一。其他类型的文件,只做格式化。
注意这里eslint --fix和prettier --write的顺序是有讲究的。我习惯先跑 ESLint 再跑 Prettier,因为 ESLint 修复过的代码可能还有格式问题,最后交给 Prettier 统一收尾,能保证输出的一致性。
如果项目里用了 Tailwind CSS 之类的工具,也可以在这个配置里补上 class 排序插件,逻辑一模一样。
4.3 跑一次真实提交,看它如何把“坏文件”拦在门口
模拟一次真实场景。我故意在src/App.tsx里写了一段没有分号、引号格式混乱还带未使用变量的代码,然后执行:
git add src/App.tsx git commit -m "test bad commit"此时终端会出现类似这样的输出:
✔ Preparing lint-staged... ✔ Running tasks for staged files... ✖ eslint --fix found some errors. Please fix them and try again.提交失败。这是因为 ESLint 发现了一些需要手动修复的问题,比如未使用的变量。像这种问题--fix修不掉,只能由开发者手工处理。
把那个未使用的变量删掉以后再试:
git add src/App.tsx git commit -m "test good commit"这次 lint-staged 会把prettier --write改完的内容重新添加进暂存区,然后正常生成 commit。
这里有个容易忽略的细节:lint-staged 执行完后,Git 会重新 add 被修改过的文件,所以你的 commit 里包含的是格式化后的版本,而不是格式化前的版本。这一点非常重要,如果你没有用 lint-staged,而是自己在 hook 里写npm run lint和npm run format,格式化后的文件不会自动回到暂存区,提交进去的仍然是旧版本。
5. 接入后最容易翻车的几个场景与排查方法
工具链搭好只是开始,真正让人头疼的是接入之后遇到的各种“不生效”。下面这几个场景,几乎每个项目组都会碰到。
5.1 提交时 hooks 完全没出现
如果你执行git commit时,终端没有任何额外输出,说明 hooks 根本没有挂上。按这个顺序排查:
git config --get core.hooksPath如果输出是.husky/_,说明 husky 管理了 hooks,问题可能在脚本本身。手动执行一次:
bash .husky/pre-commit看看会不会报错。如果是 husky 新装的但 never 生效,检查项目根目录的.husky/pre-commit文件是否存在,内容是否为npx lint-staged,以及是否有执行权限。
如果core.hooksPath返回空,说明 husky 没有初始化成功。大概率是npm install时prepare脚本没执行,可以手动跑一下:
npm run prepare5.2 lint-staged 只改了暂存文件,但格式化结果没进提交
这种情况通常出现在直接使用prettier --write而不是 lint-staged 时。前面我强调过,lint-staged 会在处理完文件后自动执行git add,但如果你自己在 hook 里写的是:
npx prettier --write . npx eslint --fix .那么这些命令虽然修改了工作区的文件,却不会自动把你的修改加进暂存区,最后提交进去的还是格式修改前的版本。更严重的是,因为你跑的是全量检查,可能把项目里原本就存在的旧错误也一并暴露出来,导致提交一直失败,但其实跟你这次改动无关。
解决方案就是回到 lint-staged 的正确配置上,不要自己写裸命令。
5.3 ESLint 9 下的配置冲突让提交一直被卡
ESLint 9 全面采用 flat config 之后,很多旧插件在新体系下会出现兼容问题,典型表现是eslint.config.js里plugin名称无法解析。如果你遇到了这类报错,先确认所有相关包的版本是支持 ESLint 9 的。
另一个常见冲突是 ESLint 规则和 Prettier 格式规则“互相打架”。表现是:eslint --fix改完,prettier --write又改回来,来回震荡,每次提交都有大量 diff。这时候检查一下有没有引入eslint-config-prettier,它会把 ESLint 里所有和格式化相关的规则关掉,让两者各管各的。
如果你用的是tseslint.config()这种辅助函数,记得把prettier配置对象放在整个数组的最后,这样它才能覆盖掉前面可能开启的冲突规则。
5.4 被人用 --no-verify 绕过以后
最后聊一个偏“纪律”的问题。git commit --no-verify可以跳过所有 Git Hooks,这是 Git 本身的机制,也是紧急情况下的逃生通道。
但必须有心理准备:一旦团队成员习惯了--no-verify,再好的自动化拦截也会变成摆设。我的建议是,把提交前校验当作“默认约定”,紧急场景下确实可以临时跳过,但跳过后要在 commit message 里留一句标记,方便后面回查。
另外,不要在 hook 里把所有检查都做完。比如完整的单元测试和构建,我认为更适合放在 CI 或 pre-push 阶段,而不是 pre-commit。因为 pre-commit 追求的是快,如果一次提交要等三分钟跑测试,开发者会想尽一切办法绕过它。把 lint、格式化这类“秒级”检查留在 pre-commit,把重活交给 CI,反而更容易落地。