第十二章. Husky v9 + lint-staged 全指南,一文带你搞懂团队协作核心

第十二章. Husky v9 + lint-staged 全指南,一文带你搞懂团队协作核心

  1. 本章摘要:本章将深入剖析 Git Hooks 的工作机制,理解为什么团队协作需要自动化质量门禁,掌握 Husky 和 lint-staged 的核心原理,最终在真实项目中构建完整的客户端质量保障体系。

本章学习路径

  1. 问题认知:理解依赖人工自觉的协作规范为何总是失败,掌握质量前移的核心价值
  2. 机制剖析:深入 Git Hooks 的底层工作原理,理解生命周期钩子的触发时机与退出码机制
  3. 工具进化:从原生 Hooks 的局限性出发,理解 Husky 如何实现配置共享,lint-staged 如何解决性能问题
  4. 工程实践:在 Vite + React 项目中搭建完整质量门禁,处理跨平台兼容性与常见故障

12.1 为什么需要自动化质量门禁

在第 11 章中,我们学习了 Git Flow 等协作模型,制定了分支管理规范和代码审查流程。但这些规范有一个致命的前提假设:所有成员都会严格遵守。现实中,这个假设几乎从未成立过。本节我们将分析规范执行失败的深层原因,理解为什么自动化质量门禁是团队协作的刚需。

团队协作现场
周五 17:30
M

小李,CI 构建又失败了,ESLint 报错说你提交的代码里有很多未使用的变量,还有格式缩进全是错的。

新人小李

啊?不好意思!我为了赶进度,写完代码直接 git commit 了,忘了一键格式化…

T
TechLead

这种情况这个月已经是第三次了。虽然我们有文档规定 “提交前必须检查”,但在紧急情况下大家很容易跳过这个步骤。

M

是的,我们需要把 “依靠自觉” 转变为 “工具强制”。

12.1.1 协作规范的执行困境

团队规范的本质是一种约束机制。在软件工程中,我们通常会制定这样的规定:提交代码前必须运行 ESLint 检查,提交信息必须符合约定式提交格式,推送前必须通过单元测试。但这些规定都依赖开发者的记忆力和自觉性。

我们来看一个典型场景。团队在文档中明确规定:提交前必须运行 npm run lint 进行代码检查。三位开发者在实际工作中的表现完全不同。

开发者 A 严格遵守规范,每次提交前都会执行检查命令,确认无误后才提交。开发者 B 在项目初期会遵守,但随着迭代压力增大,为了赶进度开始跳过检查步骤,直接提交代码。开发者 C 是新入职的成员,他根本不知道有这个规定,入职培训时没人提及,文档也没有仔细阅读。

这种情况的后果是什么?主分支被污染,持续集成构建失败,代码审查时发现大量格式问题。更严重的是,团队成员开始互相指责:是谁提交了不规范的代码?为什么不遵守规范?

核心矛盾在于:规范的执行依赖人的主观能动性,而人的行为受到时间压力、认知水平、工作状态等多重因素影响,必然存在执行偏差。

12.1.2 三个真实的失败案例

我们通过三个真实案例来理解规范失效的后果。

案例一:格式混乱引发的代码审查灾难

某电商团队的一次 Pull Request 显示:47 个文件变更,新增 1203 行,删除 856 行。审查者打开 PR 后发现,实际的业务逻辑修改只有 200 行左右,其余 1000 多行全部是格式调整,包括空格、分号、引号的变化。

这个问题的根源是:不同开发者使用了不同的 Prettier 配置。有人使用单引号,有人使用双引号;有人使用 2 个空格缩进,有人使用 4 个。当某位开发者全局格式化代码后提交,就会产生海量的格式变更,完全淹没了真正的业务逻辑改动。

代码审查者无法快速定位核心变更,必须逐行对比才能找到关键修改。原本 30 分钟能完成的审查,变成了 2 小时的折磨。更糟糕的是,这种格式冲突在合并时会产生大量无意义的冲突标记。

案例二:敏感信息泄露事故

某创业公司的开发者在修复支付接口问题时,为了方便调试,临时在 .env 文件中写入了支付宝的密钥和数据库密码。修复完成后,他直接提交了代码,没有意识到 .env 文件也被包含进去。

这次提交的后果是:凌晨收到支付宝的风控通知,有异常调用行为。排查后发现,有人从公开的 GitHub 仓库中获取了密钥,正在尝试盗刷。团队不得不紧急回滚代码,重置所有密钥,修改数据库密码,并通知所有用户可能存在的安全风险。

这个事故本可以避免。如果在提交前有自动化检查,扫描代码中是否包含密钥、密码等敏感信息,就能在本地拦截这次提交。

案例三:单元测试覆盖率的雪崩

某 SaaS 团队在版本迭代中,测试覆盖率从 87% 跌至 62%。项目负责人排查后发现,三个成员在最近两周的提交中都跳过了测试步骤。

团队规定推送代码前必须运行 npm run test,确保所有测试通过。但在进度压力下,大家都选择了跳过测试直接推送。理由是:我只改了一个小功能,应该不会影响其他模块;测试跑一遍要 5 分钟,太浪费时间了。

结果是覆盖率持续下降,到了某个临界点后,系统出现了严重的回归问题:修复 A 功能时破坏了 B 功能,而测试没有及时发现。

12.1.3 质量前移的核心价值

上述案例暴露的本质问题是:质量检查的时机太晚。传统的质量保障流程是这样的:

开发者在本地完成代码编写 -> 提交到远程仓库,触发持续集成系统运行检查,如果失败则通知开发者修复,开发者再次提交。

这个流程的平均耗时是 15 到 30 分钟。开发者提交代码后,需要等待 CI 系统排队、拉取代码、安装依赖、运行检查。如果检查失败,开发者需要切换回工作分支,修复问题,重新提交,再次等待 CI 结果。

更严重的问题是心理成本。当开发者提交代码后,他的注意力已经转移到下一个任务。突然收到 CI 失败的通知,他需要中断当前工作,回忆之前的改动,切换上下文。这种频繁的打断会严重降低工作效率。

自动化质量门禁采用完全不同的思路:在本地提交代码之前就进行检查。开发者执行 git commit 命令时,系统自动运行代码检查、格式化、类型校验等操作,如果发现问题立即中止提交,给出错误提示。

这个流程的反馈时间是 5 秒以内。开发者的注意力还在当前任务上,代码的上下文还在工作记忆中,修复问题的效率最高。而且由于是在本地检查,不会产生无效的远程提交记录,保持了 Git 历史的整洁。

核心原理 是将质量检查前置到开发者的本地环境,在问题产生的源头就进行拦截,而不是等问题扩散到远程仓库后再处理。

12.1.4 本章技术栈全景

在理解了质量门禁的价值后, 我们来看实现这个系统需要哪些技术组件。整个体系分为四层。

最底层是 Git Hooks 机制。Git 在执行某些操作时会触发特定的脚本, 比如提交前触发 pre-commit, 推送前触发 pre-push。我们利用这些钩子来插入检查逻辑。

第二层是 Husky 工具。原生的 Git Hooks 存储在 .git/hooks 目录, 这个目录不受版本控制, 无法在团队成员间共享。Husky 解决了这个问题, 它将钩子配置放在项目根目录的 .husky 文件夹, 受版本控制管理, 团队成员克隆仓库后会自动同步配置。

第三层是 lint-staged。如果每次提交都检查整个项目的所有文件, 性能会很差。lint-staged 实现了增量检查, 只对本次提交涉及的文件进行检查, 大幅提升性能。

第四层是具体的检查工具链, 包括 ESLint 进行代码质量检查, Prettier 进行代码格式化, TypeScript 进行类型校验, Vitest 运行单元测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─────────────────────────────────────────────────────┐
│ 质量门禁体系 │
├─────────────────────────────────────────────────────┤
│ Git Hooks (底层机制) │
│ ├─ pre-commit ──→ 代码格式 + 静态检查 │
│ ├─ commit-msg ──→ 提交信息校验 │
│ └─ pre-push ──→ 单元测试 │
├─────────────────────────────────────────────────────┤
│ Husky (共享配置) │
│ ├─ 修改 Git 的 hooksPath 配置 │
│ ├─ .husky 目录受版本控制 │
│ └─ 团队成员自动同步 │
├─────────────────────────────────────────────────────┤
│ lint-staged (增量检查) │
│ ├─ 只检查暂存区文件 │
│ ├─ 并行执行多个检查器 │
│ └─ 自动修复并重新暂存 │
├─────────────────────────────────────────────────────┤
│ 检查工具链 │
│ ├─ ESLint (代码质量) │
│ ├─ Prettier (代码格式) │
│ ├─ TypeScript (类型检查) │
│ └─ Vitest (单元测试) │
└─────────────────────────────────────────────────────┘

这四层组件协同工作, 构成了完整的质量门禁体系。接下来我们将逐层深入, 理解每个组件的工作原理和配置方法。


12.2 Git Hooks 核心机制剖析

在上一节中, 我们理解了为什么需要自动化质量门禁, 也看到了整个技术栈的全景图。但在使用 Husky 等工具之前, 我们必须先深入理解 Git Hooks 的底层机制, 这样才能知道这些工具到底在做什么, 遇到问题时也能准确定位。本节我们将从 Git 的内部实现出发, 剖析钩子的工作原理。

12.2.1 探索 .git/hooks 目录

当我们初始化一个 Git 仓库时, Git 会自动创建 .git/hooks 目录, 并在其中放置一些示例文件。我们来看看这个目录里有什么。

在项目根目录打开终端, 执行以下命令查看 hooks 目录的内容。

1
ls -la .git/hooks/

输出类似这样的内容。

1
2
3
4
5
6
7
8
9
10
total 96
-rwxr-xr-x 1 user staff 478 Nov 25 10:00 applypatch-msg.sample
-rwxr-xr-x 1 user staff 896 Nov 25 10:00 commit-msg.sample
-rwxr-xr-x 1 user staff 3327 Nov 25 10:00 fsmonitor-watchman.sample
-rwxr-xr-x 1 user staff 189 Nov 25 10:00 post-update.sample
-rwxr-xr-x 1 user staff 424 Nov 25 10:00 pre-applypatch.sample
-rwxr-xr-x 1 user staff 1643 Nov 25 10:00 pre-commit.sample
-rwxr-xr-x 1 user staff 416 Nov 25 10:00 pre-merge-commit.sample
-rwxr-xr-x 1 user staff 1492 Nov 25 10:00 pre-push.sample
-rwxr-xr-x 1 user staff 4898 Nov 25 10:00 pre-rebase.sample

这些文件有几个共同特征。

第一, 所有文件都以 .sample 结尾, 这表示它们是示例文件, 处于未激活状态。

第二, 文件权限显示为 -rwxr-xr-x, 其中 x 表示可执行权限。

第三, 这些都是 Shell 脚本, 可以用文本编辑器打开查看。

我们打开其中一个文件看看内部结构。

1
cat .git/hooks/pre-commit.sample

文件开头是这样的。

1
2
3
4
5
#!/bin/sh
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.

第一行 #!/bin/sh 是 Shebang, 指定这个脚本用 /bin/sh 解释器执行。注释说明了这个钩子的作用: 在提交代码前进行校验, 如果钩子以非零状态退出, 提交将被中止。这里揭示了 Git Hooks 的核心机制: 通过脚本的退出码来控制 Git 操作是否继续。在 Unix 系统中, 程序执行成功返回 0, 失败返回非零值。Git 利用这个约定来实现拦截功能。

12.2.2 Hooks 的工作原理深度解析

Git Hooks 本质上是一种事件驱动机制。当我们执行某个 Git 命令时, Git 会在特定的时间点检查是否存在对应的钩子脚本, 如果存在就执行它, 根据脚本的退出码决定是否继续原操作。

我们以提交代码为例, 完整梳理这个过程。开发者执行 git commit -m "message" 命令, Git 内部的执行流程是这样的。

首先 Git 检查 .git/hooks/pre-commit 文件是否存在。

注意这里查找的是没有 .sample 后缀的文件。如果文件不存在, 直接跳到下一步。

如果文件存在, Git 会检查它是否具有可执行权限。如果没有可执行权限, Git 会忽略它。如果有可执行权限, Git 会在一个子进程中执行这个脚本。

脚本执行完毕后, Git 检查它的退出码。如果退出码是 0, 表示检查通过, Git 继续执行提交操作。

如果退出码是非零值, 表示检查失败, Git 中止提交, 将脚本的输出显示给用户。

我们用流程图来表示这个过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌──────────────────────────────────────────────────┐
│ 开发者执行 Git 命令 │
│ $ git commit -m "message" │
└────────────────┬─────────────────────────────────┘


┌──────────────────────────────────────────────────┐
│ Git 检查钩子文件是否存在 │
│ .git/hooks/pre-commit │
│ ├─ 不存在 → 跳过 │
│ └─ 存在 → 检查可执行权限 │
└────────────────┬─────────────────────────────────┘


┌──────────────────────────────────────────────────┐
│ 执行钩子脚本 │
│ ├─ 创建子进程 │
│ ├─ 执行脚本内容 │
│ └─ 等待脚本结束 │
└────────────────┬─────────────────────────────────┘


┌──────────────────────────────────────────────────┐
│ 检查退出码 │
│ ├─ 退出码 = 0 → 继续提交 │
│ └─ 退出码 ≠ 0 → 中止提交,显示错误信息 │
└──────────────────────────────────────────────────┘

这个机制给了我们极大的灵活性。钩子脚本可以用任何可执行的语言编写, 只要第一行指定正确的解释器即可。可以是 Shell 脚本, 可以是 Python 脚本, 也可以是 Node.js 脚本。

12.2.3 常用 Hooks 的生命周期与应用场景

Git 提供了丰富的钩子类型, 覆盖了代码提交、推送、合并等各个环节。

我们重点理解客户端钩子, 它们在开发者本地执行, 用于质量控制。

  • pre-commit: 钩子在执行 git commit 命令时, 生成提交对象之前触发。这是最常用的钩子, 典型用途包括代码格式化检查、静态代码分析、检测敏感信息。因为在提交之前执行, 所以可以中止提交操作。

  • prepare-commit-msg: 钩子在 Git 生成默认提交信息之后, 打开编辑器之前触发。这个钩子接收三个参数: 提交信息文件的路径、提交类型、提交的 SHA-1 值。典型用途是自动添加 Issue 编号, 自动填充提交模板。它也可以中止提交。

  • commit-msg: 钩子在用户编辑完提交信息, 关闭编辑器之后触发, 但在提交对象真正写入之前。这个钩子接收一个参数: 包含提交信息的临时文件路径。典型用途是校验提交信息格式, 检查是否符合约定式提交规范。可以中止提交。

  • post-commit: 钩子在提交对象成功写入后触发。这个钩子主要用于通知操作, 比如发送提交通知邮件, 更新项目统计数据。因为提交已经完成, 所以无法中止提交。

  • pre-push: 钩子在执行 git push 命令时, 数据传输之前触发。这个钩子接收远程仓库的名称和 URL 作为参数。典型用途是运行单元测试, 验证代码构建, 检查分支命名规范。可以中止推送操作。

我们用表格总结这些钩子的特性。

Hook 名称触发时机能否中止操作典型应用场景
pre-commit执行 commit 之前可以代码格式、静态检查、敏感信息扫描
prepare-commit-msg生成提交信息后, 编辑器打开前可以自动添加 Issue 编号、填充模板
commit-msg编辑器关闭后, 写入提交前可以校验提交信息格式
post-commit提交完成后不可以发送通知、更新统计
pre-push执行 push 之前可以运行测试、验证构建
pre-rebase执行 rebase 之前可以检查分支状态、防止误操作

理解这些钩子的触发时机和能力边界非常重要。比如有些团队希望在推送代码后自动部署, 这应该使用服务端的 post-receive 钩子, 而不是客户端的 post-push 钩子, 因为后者只在本地执行。

12.2.4 手动创建第一个钩子

理解了原理后, 我们动手创建一个简单的 pre-commit 钩子来验证机制。这个钩子的功能是: 在提交前检查暂存区是否包含 console.log 语句, 如果包含则拒绝提交。

创建钩子文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/sh

echo "检查是否包含 console.log..."

# 获取暂存区的所有 JavaScript 文件
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.js$')

# 如果没有 JS 文件, 直接通过
if [ -z "$files" ]; then
exit 0
fi

# 检查每个文件是否包含 console.log
for file in $files; do
if grep -q 'console\.log' "$file"; then
echo "❌ 检测到 console.log 语句, 请删除后再提交 $file"
exit 1
fi
done

echo "✅ 所有文件检查通过"
exit 0
EOF

赋予执行权限。在 Linux 或 macOS 系统中, 需要手动赋予脚本执行权限。

1
chmod +x .git/hooks/pre-commit

Windows 用户需要使用 Git Bash 执行上述命令, 或者在创建文件时确保它具有可执行属性。

现在我们测试这个钩子。创建一个包含 console.log 的文件并尝试提交。

1
2
3
echo "console.log('test')" > test.js
git add test.js
git commit -m "test: 测试钩子"

预期输出是这样的。

1
2
3
🔍 检查是否包含 console.log...
❌ 检测到 console.log: test.js
提交前请移除所有调试语句

提交被中止, Git 没有创建提交对象。我们修改文件移除 console.log 再次尝试。

1
2
3
echo "// test" > test.js
git add test.js
git commit -m "test: 测试钩子"

这次输出是。

1
2
3
4
🔍 检查是否包含 console.log...
✅ 检查通过
[main abc123] test: 测试钩子
1 file changed, 1 insertion(+)

提交成功。通过这个简单的例子, 我们验证了 Git Hooks 的核心机制: 脚本通过退出码控制 Git 操作的执行流程。

12.2.5 原生 Hooks 的致命缺陷

现在我们已经成功创建并测试了一个钩子, 但如果尝试将这个钩子共享给团队成员, 就会遇到问题。我们尝试将钩子文件添加到版本控制。

1
git add .git/hooks/pre-commit

Git 会立即报错。

1
2
3
The following paths are ignored by one of your .gitignore files:
.git/hooks/pre-commit
hint: Use -f if you really want to add them.

错误信息告诉我们, .git 目录下的内容被 Git 忽略, 无法加入版本控制。这是 Git 的设计决策, .git 目录包含的是仓库的元数据和状态信息, 这些内容是仓库运行时产生的, 不应该被跟踪。

这个设计带来了严重的问题。钩子配置无法通过 Git 仓库分发给团队成员。当新成员克隆仓库后, 他的 .git/hooks 目录只包含那些 .sample 示例文件, 不包含我们创建的 pre-commit 钩子。他必须手动创建钩子文件, 手动设置可执行权限, 手动保持和团队配置的一致性。

这导致了几个严重后果。

第一, 钩子配置容易遗漏, 新成员可能根本不知道需要配置钩子。

第二, 配置容易过时, 团队更新了钩子逻辑, 但无法自动同步到所有成员的本地环境。

第三, 跨平台兼容性差, Windows 和 Unix 系统的可执行权限机制不同, 需要特殊处理。

我们需要一种方案, 能够将钩子配置纳入版本控制, 让团队成员在克隆仓库后自动获得统一的钩子配置。这就是 Husky 工具诞生的原因, 我们将在下一节详细剖析它的解决方案。

12.2.6 本节小结

本节我们深入探索了 Git Hooks 的底层机制。核心要点包括:

  • Git Hooks 是一种事件驱动机制, 通过脚本的退出码控制 Git 操作的执行流程
  • pre-commit、commit-msg、pre-push 等钩子覆盖了提交和推送的各个环节, 可用于质量控制
  • 钩子脚本可以用任何语言编写, 只要正确设置 Shebang 并具有可执行权限
  • 原生 Hooks 存储在 .git/hooks 目录, 无法纳入版本控制, 这是其最大的局限性

12.3 Husky:让配置可共享的革命

在上一节中,我们理解了 Git Hooks 的工作原理,也发现了原生方案的致命缺陷:配置无法在团队间共享。现在我们将学习 Husky 工具,它通过巧妙的设计解决了这个问题,让钩子配置像其他代码一样可以版本控制和自动同步。本节我们将深入 Husky 的最新版(v9+)实现原理,理解它如何改变 Git 的默认行为。

12.3.1 Husky 解决的核心问题

Husky 机制解析
2024-05-20 10:00
M

Husky 主要是为了解决什么问题?

E
expert

一句话概括:让 Git Hooks 配置受版本控制管理,并在团队成员间自动同步。

M

为什么传统的 .git/hooks 目录做不到这点?

E
expert

因为 .git 目录不受版本控制。Alice 在本地配置了钩子,无法推送到远程。Bob 克隆代码后,不仅没有这些钩子,还得手动复制脚本并设置权限。

E
expert

一旦 Alice 修改了逻辑,Bob 毫无感知,这会导致团队成员的开发环境不一致。

M

那 Husky 是怎么解决的?

E
expert

Husky 利用 Git 的 core.hooksPath 配置,把钩子查找位置重定向到项目根目录下的 .husky 文件夹。

E
expert

这个文件夹可以提交到 Git 仓库。Bob 只要运行 npm install,配置就会自动生效,从而直接使用同步下来的钩子脚本。

M

明白了,通过改变查找路径来实现版本控制,很巧妙。

12.3.2 Husky 的工作原理深度解析

Husky 的设计非常精妙,它并没有魔改 Git 的核心功能,而是利用 Git 自身的配置能力配合 Node.js 生态来实现自动化。我们以最新的 Husky v9 版本为例来拆解它的工作流程。

第一步是 初始化阶段。当我们执行 npx husky init 命令时,Husky 会自动完成所有基础配置:它在项目根目录创建 .husky 文件夹,并在其中生成一个默认的 pre-commit 钩子文件(通常内容为 npm test);同时,它会在 package.jsonscripts 中添加一个 prepare 脚本。

prepare 是 npm 的生命周期钩子,它会在执行 npm install 之后自动运行。Husky 利用这一机制,确保任何人在克隆项目并安装依赖后,Husky 的配置脚本都会自动执行。

第二步是 配置生效阶段。当 prepare 脚本运行(即执行 husky 命令)时,它会修改本地的 Git 配置。我们可以通过以下命令验证这一点:

1
git config core.hooksPath

输出应该是:

1
.husky

这意味着 Git 已经被告知:“不要去 .git/hooks 找钩子了,请去项目根目录下的 .husky 文件夹找。”

第三步是 钩子执行阶段。当你执行 git commit 时,Git 会遵循 core.hooksPath 的指引,去 .husky 目录下查找名为 pre-commit 的可执行文件并运行它。

在 Husky v9 中,钩子文件变得非常纯粹,通常只是一个标准的 Shell 脚本,不再需要像旧版本那样在文件头部包含大量复杂的样板代码(Boilerplate)。这使得钩子脚本更易读、易维护。

我们用流程图来梳理这个完整的生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─────────────────────────────────────────┐
│ 开发者 A (项目初始化者) │
│ 1. npx husky init │
│ 2. 修改 .husky/pre-commit │
│ 3. git push (将 .husky 目录推送到远程) │
└──────────────┬──────────────────────────┘


┌─────────────────────────────────────────┐
│ 远程仓库 │
│ .husky/pre-commit (受版本控制) │
└──────────────┬──────────────────────────┘


┌─────────────────────────────────────────┐
│ 开发者 B (团队成员) │
│ 1. git clone │
│ 2. npm install │
│ └─ 自动执行 prepare 脚本 │
│ └─ git config core.hooksPath .husky │
└──────────────┬──────────────────────────┘


┌─────────────────────────────────────────┐
│ 开发者 B 的后续操作 │
│ git commit │
│ └─ Git 从 .husky 读取钩子 │
│ └─ 自动执行团队统一的检查逻辑 │
└─────────────────────────────────────────┘

12.3.3 基础配置与验证

理解了原理后,我们在真实项目中配置 Husky。假设我们有一个 Vite + React 项目,已经初始化了 Git 仓库。

1. 安装与初始化

在 Husky v9 中,安装和初始化被合并为一个简便的命令:

1
2
3
4
# 使用 vite 新增一个 react + ts 项目
pnpm create vite@latest git-hooks-demo -- --template react-ts
# pnpm 里的 “npx” 比 npx 快的多
pnpm dlx husky init

这个命令会自动执行以下操作:

  1. 安装 husky 到开发依赖。
  2. 在根目录创建 .husky 文件夹。
  3. 创建 .husky/pre-commit 文件,默认内容为 npm test
  4. package.json 中添加 "prepare": "husky"

我们检查 package.json,应该能看到:

1
2
3
4
5
6
7
8
{
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.0.0"
}
}

2. 编写钩子逻辑

注意:Husky v9 废弃了 husky add 等命令。现在添加或修改钩子,只需要像编辑普通文件一样操作即可。

查看生成的 .husky/pre-commit 文件,默认内容可能如下:

1
pnpm test

我们将它修改为实际需要的检查逻辑,例如运行 ESLint。你可以直接用编辑器打开修改,或者使用命令行:

1
echo "pnpm run lint" > .husky/pre-commit

如果你的钩子逻辑比较复杂,建议写成脚本形式:

1
2
3
echo "🔍 执行提交前检查..."
pnpm run lint
echo "✅ 检查通过"

3. 验证效果

我们创建一个包含语法错误的文件来测试钩子是否生效:

1
2
3
echo "const x = " > test.ts
git add test.ts
git commit -m "test: 测试 Husky"

预期输出应包含 ESLint 的报错信息,并且提交被 Git 中止(Aborted)。这证明 Husky 成功拦截了这次提交。

修复错误后再次提交:

1
2
3
echo "const x = 1" > test.ts
git add test.ts
git commit -m "test: 测试 Husky"

此时提交应当成功,说明检查已通过。

12.3.4 跨平台兼容性处理

虽然 Husky 极大地简化了钩子管理,但在跨操作系统协作(如 Windows 和 macOS 混用)时,仍需注意以下细节:

1. 可执行权限 (Executable Permissions)

在 Linux 和 macOS 上,钩子文件必须具有可执行权限(+x)。Husky v9 通常会自动处理这个问题,但在某些特殊环境(如 WSL)或者手动创建文件时,你可能需要手动授权:

1
chmod +x .husky/pre-commit

2. 换行符问题 (Line Endings)

这是最常见的坑。Windows 默认使用 CRLF 换行,而 Shell 脚本在 Linux/Mac 环境下必须使用 LF 换行。如果脚本文件包含 CRLF,执行时可能会报 command not found 或解释器错误。

为了一劳永逸地解决这个问题,建议在项目根目录创建或修改 .gitattributes 文件,强制指定 .husky 目录下的文件使用 LF 换行符:

1
2
3
# .gitattributes
* text=auto
.husky/* text eol=lf

这个配置告诉 Git:无论在什么操作系统上 checkout 代码,.husky 目录下的文件始终保持 LF 换行符。

3. 路径分隔符

在钩子脚本中编写路径时,始终使用正斜杠 /(例如 src/App.tsx),因为 Git Bash (Windows) 和 Unix Shell 都支持这种格式,而反斜杠 \ 在 Unix 系统下会被视为转义字符。

12.3.5 本节小结

本节我们学习了 Husky 如何通过现代化的方式管理 Git Hooks。核心要点包括:

  • 机制变革:Husky 利用 core.hooksPath 将钩子配置权从隐藏的 .git 目录移交给了项目根目录的 .husky 文件夹。
  • 自动化npx husky initprepare 脚本的配合,确保了团队成员只需 npm install 即可获得完全一致的钩子环境。
  • v9 新特性:Husky v9 大幅简化了命令,废弃了 husky install/add,现在通过直接编辑文件来管理钩子,更加符合直觉。
  • 最佳实践:使用 .gitattributes 强制 LF 换行符,是跨平台团队协作的必要保障。

12.4 lint-staged:增量检查的性能革命

在上一节中,我们成功利用 Husky v9 实现了钩子配置的团队共享。但随着项目推进,一个新的痛点很快浮现:性能。每次提交都对整个项目进行全量扫描,这在几千个文件的大型项目中是不可接受的。本节我们将引入 lint-staged 工具,它通过“只检查暂存区文件”的策略,将质量门禁的耗时从分钟级压缩到秒级。

12.4.1 全量检查的性能瓶颈与心理博弈

当前我们在 .husky/pre-commit 中简单粗暴地配置了 npm run lint。这种做法在项目初期(Demo 阶段)是可行的,但在真实工程中会引发严重的 “破窗效应”

让我们算一笔账:假设项目包含 800 个 TypeScript 文件。

  • 场景:开发者仅修改了 Button.tsx 中的一行样式代码。
  • 动作:执行 git commit
  • 后果:ESLint 启动,解析全部 800 个文件的 AST(抽象语法树),运行数百条规则。
  • 耗时:在普通开发机上可能耗时 20~40 秒。

这就构成了开发体验(DX)的核心矛盾:极小的变更成本 vs 极大的检查成本

当开发者发现改一个标点符号都要等半分钟时,心态会发生微妙的变化。最终,他们会开始使用 git commit --no-verify 或者 -n 参数跳过检查。一旦有人带头打破规则,质量门禁就会形同虚设,我们又回到了依靠“人工自觉”的原始时代。

我们需要一个方案,既能捍卫代码质量,又不侵占开发者的咖啡时间。

12.4.2 增量检查与自动修复原理

lint-staged 的核心理念非常简单且强大:只检查本次提交涉及的文件(Staged Files)

这个工具的工作流程在最新版本中已经高度进化,不仅仅是简单的“过滤文件”。它的内部状态机包含了一个关键的“安全回滚”机制。让我们深入解剖当你执行 git commit 时的完整数据流:

  1. 快照备份
    lint-staged 启动的第一件事,不是检查代码,而是 保护现场。它会将当前未暂存(Unstaged)的修改隐式地存储起来。这是为了防止在检查过程中,脚本意外修改了开发者还没准备好提交的代码。

  2. 获取列表与过滤:通过 git diff --cached --name-only 获取暂存区文件列表。lint-staged 会根据配置文件(如 *.ts)对这些文件进行二次筛选,剔除已删除的文件。

  3. 任务分发:这是性能提升的关键。假设你修改了 3 个 TS 文件和 2 个 CSS 文件。lint-staged 会 并行 启动两个独立的进程:

    • 进程 A:eslint file1.ts file2.ts file3.ts
    • 进程 B:stylelint file1.css file2.css注意:它不是对每个文件启动一次 ESLint(那样启动开销太大),而是将文件路径作为参数一次性传递给 CLI 工具。
  4. 自动修复与更新:如果配置了 eslint --fix,文件内容在检查过程中发生了变化(例如自动加了分号)。lint-staged 会检测到这些变更,并 自动执行 git add 将修复后的结果更新到暂存区。这一步是全自动的,无需人工干预。

  5. 恢复现场:无论检查成功与否,lint-staged 都会将第一步备份的未暂存修改恢复回来。

机制解惑
2024-05-20 14:00
M

听说以前版本的 lint-staged 需要手动 git add?

E
expert

是的,在 v10 版本之前,你需要显式地在配置里写 git add

E
expert

但现在的 lint-staged 已经完全自动化了。如果 linter 修改了代码,它会自动把修改后的部分暂存进去。

M

那如果检查过程中报错了怎么办?

E
expert

只有所有检查都通过(Exit Code 0),提交才会继续。否则会回滚暂存区的变化,并打印错误日志,阻止提交。

12.4.3 现代化配置实战

虽然可以在 package.json 中配置,但为了保持项目根目录的整洁,以及支持更复杂的逻辑(如类型检查),业界推荐使用独立的配置文件。

1. 安装依赖

1
pnpm install -D lint-staged

2. 创建配置文件

在根目录创建 .lintstagedrc (JSON 格式) 或者 .lintstagedrc.js (JS 格式)。这里我们推荐使用 JSON 格式作为起步:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,less}": [
"stylelint --fix",
"prettier --write"
],
"*.{json,md,yml}": [
"prettier --write"
]
}

配置解析:

  • 键(Key):Glob 匹配模式。*.{js,jsx,ts,tsx} 命中所有脚本文件。
  • 值(Value):要执行的命令数组。注意数组的顺序就是执行顺序。
    • 先跑 eslint --fix:让 Linter 修复逻辑和部分格式问题。
    • 后跑 prettier --write:让 Prettier 统一最终的代码风格。
    • 不需要写文件名:lint-staged 会自动把匹配到的文件名追加到命令后面。例如实际执行的是 eslint --fix src/App.tsx

3. 集成到 Husky

修改 .husky/pre-commit 文件。由于上一节我们已经升级到了 Husky v9,现在的钩子文件非常清爽,不需要任何 Shell 样板代码:

1
pnpm dlx lint-staged

4. 验证测试

创建一个故意写得很乱的 TypeScript 文件 src/test.ts

1
2
// src/test.ts
const x= 1; console.log(x)

添加到暂存区并提交:

1
2
git add src/test.ts
git commit -m "test: 验证增量检查"

你应当看到类似以下的输出流,注意观察 lint-staged 的执行步骤:

1
2
3
4
5
6
7
8
9
10
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ .lintstagedrc — 1 file
✔ *.{js,jsx,ts,tsx} — 1 file
✔ eslint --fix
✔ prettier --write
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
[main 8a9b1c] test: 验证增量检查
1 file changed, 1 insertion(+)

再次查看文件,console.log 被删除(如果 ESLint 配置了禁用 console),空格和分号被 Prettier 自动补全。

12.4.4 性能对比与参数限制优化

我们来实测一下引入 lint-staged 前后的性能差异。测试环境为一个包含 1200 个文件的中型 React 项目。

修改文件数全量检查 (npm run lint)增量检查 (lint-staged)提速倍率
1 个文件28.5 秒1.2 秒23.7x
5 个文件29.1 秒1.8 秒16.1x
20 个文件31.0 秒4.5 秒6.8x

进阶知识点:命令行参数长度限制

当一次性提交大量文件(例如几百个)时,lint-staged 可能会遇到操作系统的 “Command line too long” 错误。

lint-staged 内置了分块(Chunking)机制来解决这个问题。它会自动将大量文件切分成多个批次(Chunks)分别执行。

如果你需要手动控制这个行为,可以在 .lintstagedrc 中配置:

1
2
3
4
5
6
{
"*.ts": ["eslint --fix"],
"options": {
"maxArgLength": 10
}
}

但在 99% 的场景下,默认配置已经足够智能。

12.4.5 本节小结

本节我们解决了工程化落地中最实际的性能阻碍。核心要点如下:

  • 痛点解决:全量检查的线性时间复杂度是导致 CI/CD 流程在本地崩溃的元凶,增量检查将其降维为常数级或对数级。
  • 机制闭环:lint-staged 通过 Backup -> Resolve -> Run -> Apply -> Restore 的状态机循环,确保了代码修改的安全性与原子性。
  • 零配置自动暂存:现代版 lint-staged 不再需要手动 git add,减少了配置文件的维护成本。
  • 并行与分块:工具内部对并发执行和超长参数列表做了优化,适应各种规模的工程。

现在,我们的提交过程既快又稳。但在实际工作中,我们往往需要更复杂的检查体系,比如 Commit Message 格式校验、单元测试等。下一节,我们将把所有这些环节串联起来,搭建一套企业级的完整质量门禁系统。

12.5 实战:搭建完整质量门禁系统

在前面的章节中, 我们分别学习了 Git Hooks、Husky、lint-staged 的原理和配置方法。现在我们将这些知识整合, 在一个真实的 Vite + React + TypeScript 项目中, 搭建一套完整的质量门禁系统。本节我们将处理实际工程中的各种细节问题, 包括工具链配置、跨平台兼容、故障排查等。

12.5.1 项目初始化与工具链配置

我们使用 Vite 官方模板创建项目。

1
2
3
pnpm create vite@latest git-hooks-demo -- --template react-ts
cd git-hooks-demo
pnpm install

初始化 Git 仓库并提交初始状态。

1
2
3
git init
git add .
git commit -m "chore: 初始化项目"

接下来配置 ESLint。安装必要的依赖。

1
2
pnpm install -D eslint @eslint/js typescript-eslint \
eslint-plugin-react-hooks eslint-plugin-react-refresh

创建 eslint.config.js 配置文件。我们采用 ESLint 9 的扁平化配置格式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import globals from "globals";
import eslintJs from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintPluginReact from "eslint-plugin-react";
import eslintPluginReactHooks from "eslint-plugin-react-hooks";
import eslintConfigPrettier from "eslint-config-prettier";

export default tseslint.config(
// 全局忽略文件
{
ignores: ["dist", "node_modules", "*.config.js", "public"],
},
// 应用于所有文件的基础配置
eslintJs.configs.recommended,
...tseslint.configs.recommended,
// React 相关的专属配置
{
files: ["src/**/*.{ts,tsx}"],
plugins: {
react: eslintPluginReact,
"react-hooks": eslintPluginReactHooks,
},
languageOptions: {
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
},
},
rules: {
...eslintPluginReact.configs.recommended.rules,
...eslintPluginReactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off", // React 17+ 无需在作用域中引入 React
},
},
// 必须放在最后,用于关闭与 Prettier 冲突的规则
eslintConfigPrettier
);

这个配置继承了 ESLint 和 TypeScript 的推荐规则, 添加了 React Hooks 的规则, 并自定义了两条规则: 未使用的变量报错, console 语句警告。

package.json 中添加检查脚本。

1
2
3
4
5
6
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}
}

配置 Prettier。安装依赖。

1
pnpm install -D prettier eslint-config-prettier eslint-plugin-prettier

创建 .prettierrc 文件。

1
2
3
4
5
6
7
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80
}

创建 .prettierignore 文件。

1
2
3
4
dist
node_modules
coverage
pnpm-lock.yaml

更新 ESLint 配置, 集成 Prettier。在 eslint.config.js 末尾添加。

1
2
3
4
5
6
import eslintPluginPrettier from 'eslint-plugin-prettier/recommended'

export default tseslint.config(
// 前面的配置...
eslintPluginPrettier,
)

package.json 中添加格式化脚本。

1
2
3
4
5
6
{
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}

现在工具链配置完成, 我们验证一下是否正常工作。

1
2
pnpm run lint
pnpm run format:check

如果没有错误输出, 说明配置成功。

12.5.2 集成 Husky 和 lint-staged

安装 Husky 和 lint-staged。

1
pnpm install -D husky lint-staged

初始化 Husky。

1
npx husky init

package.json 中添加 lint-staged 配置。

1
2
3
4
5
6
7
8
9
10
11
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
}
}

编辑 .husky/pre-commit 文件, 配置提交前检查。

1
2
3
4
5
6
7
8
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo "🔍 [Pre-Commit] 检查暂存区文件..."
npx lint-staged

echo "🔍 [Pre-Commit] TypeScript 类型检查..."
npx tsc --noEmit

这个钩子会先用 lint-staged 检查暂存区的文件, 然后运行 TypeScript 类型检查。类型检查是全量的, 因为类型问题可能跨文件传播, 单独检查一个文件无法发现所有问题。

创建 .husky/pre-push 文件, 配置推送前检查。

1
2
3
4
5
6
7
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo "🏗️ [Pre-Push] 验证构建..."
npm run build

echo "✅ [Pre-Push] 检查通过"

这个钩子在推送前验证代码是否能够成功构建, 确保不会推送无法构建的代码到远程仓库。

创建 .gitattributes 文件, 处理跨平台换行符问题。

1
2
3
* text=auto
*.sh text eol=lf
.husky/* text eol=lf

现在完整的质量门禁系统已经搭建完成。提交当前配置。

1
2
git add .
git commit -m "chore: 配置质量门禁系统"

12.5.3 测试与验证

我们通过几个测试场景验证系统是否正常工作。

场景一: 提交格式错误的代码。创建一个文件, 故意写入格式问题。

1
2
3
// src/test.ts
import {useState} from 'react'
const obj={a:1,b:2}

尝试提交。

1
2
git add src/test.ts
git commit -m "test: 格式测试"

观察输出, lint-staged 会自动修复格式, 提交成功。查看修复后的内容。

1
git show HEAD:src/test.ts

应该看到格式已经规范化。

场景二: 提交有类型错误的代码。

1
2
// src/error.ts
const num: number = 'string'

尝试提交。

1
2
git add src/error.ts
git commit -m "test: 类型错误"

TypeScript 类型检查会失败, 提交被中止。修复错误后才能提交。

场景三: 推送有构建错误的代码。在某个组件中引入一个不存在的模块。

1
import { nonExist } from './non-exist'

提交这个文件, 然后尝试推送。

1
2
3
git add src/App.tsx
git commit -m "test: 构建错误" --no-verify
git push

pre-push 钩子会运行构建, 构建失败, 推送被中止。

12.5.4 常见故障排查

在实际使用中, 可能会遇到一些问题。我们总结几个常见的故障和排查方法。

问题一: Husky 钩子未生效

现象是执行 git commit 时没有任何检查输出, 直接提交成功。

排查步骤: 首先检查 Git 配置。

1
git config core.hooksPath

应该输出 .husky。如果输出为空, 说明 Husky 没有正确初始化。手动设置。

1
git config core.hooksPath .husky

检查钩子文件权限。

1
ls -la .husky/pre-commit

应该具有可执行权限。如果没有, 手动赋权。

1
chmod +x .husky/pre-commit

问题二: lint-staged 执行卡住

现象是运行 lint-staged 后长时间没有响应。

排查步骤: 启用调试模式查看详细日志。

1
DEBUG=lint-staged* npx lint-staged

检查配置是否有死循环。错误的配置示例:

1
2
3
4
5
{
"lint-staged": {
"*.ts": "git add"
}
}

这个配置会导致无限循环, 因为 lint-staged 会自动执行 git add, 不应该在命令中再次添加。

问题三: Windows 上钩子无法执行

现象是在 Windows 上提示找不到钩子文件。

解决方案: 确保使用 Git Bash 而不是 CMD 或 PowerShell。检查换行符配置。

1
git ls-files --eol | grep husky

应该显示 i/lf w/lf。如果显示 i/crlf, 需要重置换行符。

1
2
git add --renormalize .
git commit -m "chore: 修复换行符"

12.5.5 本节小结

本节我们在真实项目中搭建了完整的质量门禁系统。核心要点包括:

  • ESLint 和 Prettier 的配置需要避免规则冲突, Prettier 应该放在 ESLint 之后执行
  • pre-commit 钩子适合快速检查, pre-push 钩子适合耗时的全量检查如构建和测试
  • 跨平台兼容性需要通过 .gitattributes 文件统一换行符
  • 遇到问题时, 检查 Git 配置、文件权限、换行符格式是常见的排查方向

12.6 本章总结

在本章中, 我们系统学习了 Git Hooks 客户端自动化拦截机制。我们从协作规范执行困境出发, 理解了为什么依赖人工自觉的规范总是失败, 质量门禁必须自动化。我们深入剖析了 Git Hooks 的底层机制, 理解了钩子通过退出码控制 Git 操作的原理, 也发现了原生方案无法共享配置的局限性。

我们学习了 Husky 如何通过修改 core.hooksPath 配置, 将钩子目录重定向到项目根目录, 实现了配置的版本控制和团队同步。我们理解了 lint-staged 如何通过增量检查解决性能问题, 将检查耗时从 18 秒降低到 1 秒, 让质量门禁真正可用。

最后我们在 Vite + React 项目中搭建了完整的质量门禁系统, 处理了工具链配置、跨平台兼容、故障排查等实际工程问题。现在我们掌握了在真实项目中落地自动化质量保障的能力。

核心知识速查

工具核心功能解决的问题
Git Hooks在 Git 操作的特定时机执行脚本提供拦截能力
Husky让钩子配置受版本控制团队配置同步
lint-staged只检查暂存区文件解决性能问题
ESLint代码质量检查发现潜在问题
Prettier代码格式化统一代码风格

在下一章中, 我们将在本章搭建的基础设施之上, 实现提交规范的自动化校验。我们将学习 commitizen 提供交互式提交引导, commitlint 校验提交信息格式, standard-version 自动生成版本号和 CHANGELOG, 构建完整的发版工作流。这将是质量门禁体系的最后一块拼图。