ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Husky 与 lint-staged 实战:从 Git Hooks 到高效提交规范

Husky 与 lint-staged 实战:从 Git Hooks 到高效提交规范 在不少前端团队待过我发现真正决定代码质量与提交效率的分水岭往往不在代码评审而在 git commit 之前那一下。Husky 负责把 Git Hooks 变成团队共享的工程规范lint-staged 则把 lint 和格式化限定在暂存区文件上保证每次提交只检查自己这次改动的东西。这篇文章我会把这两个工具的底层机制、版本差异、配置玩法、以及实际项目中会遇到的坑一次讲透让团队能真正把提交规范落到日常操作里。1. 这俩工具解决的根本问题为什么不该全仓跑 lint很多人第一次接触 husky lint-staged 时有个疑问我直接在 package.json 里写个 lint 脚本提交前跑一遍不行吗行但代价很快会让你受不了。我见过不少项目在 pre-commit 里写npm run lint结果提交一次要等半分钟甚至几分钟后来大家纷纷绕过 hook。这个问题不是 hook 机制造成的而是范围选错了。1.1 全仓 lint 的性能陷阱与误报全仓 lint 的问题主要体现在三方面。第一是性能一个中大型前端项目可能有几千个组件文件每次提交都全量扫描跑脚本的耗时是分钟级的。就算用 ESLint 的缓存首次运行时冷启动加上大文件解析依旧非常难受。第二是历史质量问题老项目里大量历史遗留代码本来就没过 lint 规则可能欠了一堆any、未使用变量、console.log。如果每次提交都全量跑哪怕你只改了一行注释也会被这些历史债拦住。第三是误报干扰当你紧急修 Bug 提交时lint 出来几十个和本次改动毫无关系的旧错误你会被迫去改那些不相干的代码改出问题谁来负责lint-staged 的思路简单粗暴却非常有效只拿这次进入暂存区staged的文件去跑规则。它通过git diff --staged --name-only之类的指令拿到文件列表再做 glob 匹配只对匹配上的文件执行 lint 和 format。这样本地 100ms 能完成的事绝不让全仓库承担你提交的每一行代码都是经过校验的历史问题不会被反复翻出来。1.2 Git Hooks 的原始样子与痛点Git 自带的 Hooks 机制本身就是给这个场景准备的。每个 Git 仓库下都有一个.git/hooks/目录里面有pre-commit、commit-msg、pre-push等模板。你要是进去看一眼会发现它们默认是不能直接执行的示例脚本。问题是这些文件藏在.git目录里.git这个目录天然不被版本库跟踪。也就是说你在自己机器上改好 pre-commit 逻辑同事 clone 仓库之后根本不会拿到这个 hooks。以前我见过不少团队用的是管理员手动拷贝或写个 shell 脚本来安装 hooks这种土办法一旦有人 clone 后忘了跑安装脚本整个规范对他来说就是透明的想怎么提就怎么提。Husky 解决的正是让 hooks 配置跟着仓库走并且能自动生效。它把你写的 pre-commit 脚本放到项目根目录的.husky下然后通过 git 的core.hooksPath配置让 Git 把 hooks 指向这个目录。因为.husky是普通目录会被 git 跟踪所以团队成员只要正常安装依赖hooks 就已经配好。这个设计比 v4 时代在 node_modules 里动态写.git/hooks要清晰得多后面会详说版本差异。2. Husky 的版本演进与配置迁移从 v4 到 v7 的坑Husky 这几年迭代了不少破坏性版本很多老项目升级时卡住就是因为没理解它换了一个实现方式。我自己是从 v4 时代用起的经历过迁移到 v6、v7 的过程这里面有几个必须搞清楚的变化点。2.1 v4 的 .huskyrc 与 Node 机制Husky v4以及更早的 v1~v3的配置方式是写在package.json里{ husky: { hooks: { pre-commit: npm run lint, commit-msg: commitlint -E HUSKY_GIT_PARAMS } } }它利用 npm 安装依赖时的postinstall钩子在node_modules/husky内部执行一段脚本把上述命令改写到 .git/hooks 下面生成实际的 hook 脚本。所以只要你在项目里npm install过 huskyhooks 就被动态写到了本地的.git/hooks里。v4 用起来确实方便但有明显问题。一是它依赖 npm 的执行时机如果团队用的是 pnpm 或者 yarn v2PnP 模式或者你直接git clone之后还没装依赖hook 就不存在。二是它是往.git/hooks里写文件这个目录不受 git 管理所以不同成员之间很容易因为 node_modules 安装环境不同导致 hooks 版本不一致。三是老版本里嵌套仓库、子模块的场景经常失效。2.2 v6/v7 的 prepare 脚本初始化与核心钩子Husky v5 开始彻底改变了方案不再往.git/hooks里写脚本而是让 git 通过core.hooksPath指向项目根目录下的.husky文件夹。你项目里的 hooks 文件本身是普通文本文件因此能被 git 用普通 diff 跟踪团队成员看到的就是一个文件。到了 v6、v7官方推荐的初始化方式是npx husky init或者拆分理解npm pkg set scripts.preparehusky install npm run prepare其中npm pkg set会在 package.json 中写入{ scripts: { prepare: husky install } }prepare脚本非常关键无论是npm install、npm ci还是git clone后第一次npm installnpm 都会自动执行prepare脚本。也就是说husky install会在装依赖阶段自动被执行然后把.husky目录找出来并设置core.hooksPath指向它。如果你用的是 pnpm同样建议保留prepare不过 pnpm 版本较新时要注意 pnpm 默认也会执行prepare但如果是构建服务器场景时需要--ignore-scripts的情况就得手动跑一次husky install。整个流程下来最直接的判断方法就是git config core.hooksPath正常配置下输出应该是.husky相对路径。如果这条命令什么都不输出说明 hooks 没被启用。2.3 常见坑dubious ownership、.git 目录与权限迁移到新版本后最容易踩的坑有三个。第一个是dubious ownership。很多团队项目放在/home/xxx/shared_folder或者/workspace/这种挂载目录、从 Windows 访问 WSL 的/mnt目录、或者公司统一挂载的盘符里git 会提示类似fatal: detected dubious ownership in repository at /path/to/repo这是 Git 为了防攻击引入的安全机制因为仓库目录所有者与当前用户不一致。第一次遇到别慌用安全的全局方法git config --global --add safe.directory /path/to/repo但要提醒团队注意不要为了省事直接safe.directory *。如果整个系统配了全部目录可信安全防护等于没开。规范做法是把具体路径加进去。在项目根目录跑mkdir -p .husky后如果发现husky install报这个错先确认是不是这个原因再逐一加白名单。第二个坑是hooks 文件没有执行权限。husky init生成的.husky/pre-commit文件默认是带x权限的但如果你用husky add手动添加 hook或者在 Windows 上通过某种编辑器保存文件权限可能会丢。Git 在运行 hook 时会先检查这个文件是否可执行不可执行会直接跳过这也是明明配了 hook 却不生效的一个经典原因。排查方法ls -l .husky/pre-commit如果没有-rwxr-xr-x里的 x执行chmod x .husky/pre-commit第三坑是把 hooks 误配到了别的目录。比如项目用了 monorepo子包内部也放了.husky但core.hooksPath是仓库级别的它只能有一个指向。如果仓库根目录和子目录都初始化了 husky最后谁后执行谁覆盖很容易出现改了子包 hook 不生效改根目录的才对。所以 monorepo 建议统一在根目录维护一套.husky文件子包不要各自初始化。3. lint-staged 的工作机制与实际匹配玩法理解 lint-staged核心是认清它其实是一个中转调度器。它自己不负责具体规则只是把暂存区文件名读出来、按 glob 过滤然后分发给 eslint、prettier 等工具去执行。它最值钱的设计在于“自动把修改后的文件再加回暂存区”这个细节如果不理解会踩很多自以为是的坑。3.1 暂存区数据流为什么它只处理暂存文件Git 的提交流程里开发者的改动先在工作区然后git add把内容放进暂存区Index。最终git commit打包的是暂存区的内容而不是工作区的全部。如果 hook 里去 lint 工作区的文件那么你和同事看到的可能是改了一半代码的中间状态lint 结果就会忽好忽坏。lint-staged 的做法是站在暂存区这一侧只列出已经被git add过的文件对这些文件运行命令。这样做有另一个隐藏好处——如果你的 lint 规则带了自动修复--fix修改发生在工作区的文件上此时文件内容和暂存区内容又不一致了。如果让它孤零零地在暂存区里躺着commit 提交的还是修复前的代码那你这个修复等于白做了。所以 lint-staged 在命令执行完以后会检查哪些文件被改动了然后自动git add它们保证修复结果真的进入本次提交。这个重新暂存的动作从 v10 之后基本是默认行为你不需要在命令列表里额外写git add。但要注意如果格式化工具做了删除或重命名文件的操作自动 add 可能无法覆盖所有变化极端情况下还是要人工确认。3.2 glob 匹配规则细节与多文件类型配置lint-staged 的匹配引擎底层是 minimatch类 glob。它接收的是相对于仓库根目录的路径默认会忽略.gitignore中排除的文件因为那些文件根本不会进暂存区。常用写法{ *.{js,ts,jsx,tsx}: eslint --fix, *.{css,scss,less}: stylelint --fix, *.{js,ts,jsx,tsx,json,md,yaml,yml}: prettier --write }注意这里*.{js,ts}只会匹配当前目录和子目录下所有对应扩展名文件吗准确说*.js在 minimatch 里默认不递归会匹配所有层级因为 minimatch 与 shell glob 的隐式递归不同lint-staged 官方文档告诉我们如果写在配置里的 key 没有斜杠它会自动用**/前缀预处理。也就是说*.js相当于**/*.js会匹配所有目录层级里的 JS 文件。如果你只想匹配 src 下的文件可以写{ src/**/*.{js,ts}: eslint --fix }这里要注意**/的语义和普通 shell 不一样src/**/*.ts会匹配src/a.ts和src/a/b.ts属于常见认知。更稳妥的做法是始终在配置里写明确路径。另一个细节同一个文件被多条规则命中时命令执行顺序是数组内串行、global 数组间并行的。比如{ *.js: [eslint --fix, prettier --write] }这个配置会对同一个.js文件先跑 eslint再跑 prettier串行执行。但要是在同一层级再写一个*.js: [cmd]JSON 里 key 重复了这本身就不是有效的对象语义所以不要这样写。lint-staged 用对象配置时最高效的做法是让不同文件类型的规则并行跑而对同一文件的多个工具用数组串行避免格式化与 lint 之间互相覆盖。3.3 任务执行器数组命令、函数与动态重暂存lint-staged 的命令值除了写字符串还可以写成数组或函数这是它的进阶玩法。数组形式就是按序执行{ *.ts: [prettier --write, eslint --fix, tsc --noEmit] }函数形式给了你更大灵活性。例如你想把多个文件名拼到一条命令里减少 ESLint 进程数{ *.{ts,tsx}: (filenames) [ prettier --write filenames.join( ), eslint --fix filenames.join( ) ] }不过要小心文件名里如果带空格直接 join 会把路径拆散导致命令失败。更稳妥的做法是用相对路径lint-staged 传进来的默认就是相对路径再配合 shell 的引用处理。lint-staged 也提供了filenames的完全形式filenames) ...。在大型 monorepo 里你可以让函数根据文件所在目录动态拼接--config参数比如给packages/a的文件用专属 eslint 配置。函数返回 false 或空数组时表示本次没有命令要执行。这个特性很适合做有条件的检查比如只对新增文件做某些严格校验而修改文件只做基础检查。最后提一下 lint-staged 的 stash 机制。默认情况下它会用git stash把未暂存的工作区改动临时保存待命令执行完再恢复。这样做是为了确保被检查的暂存区内容不受到未暂存改动的影响。有几个参数可以调节--no-stash关闭 stash可能影响检查到未暂存的内容--diff...指定 diff 基准。在正常开发流程里使用默认 stash 就好但如果你在命令里用了体积很大的依赖stash 偶尔会闪出Saved working directory and index state这种日志不必害怕。4. 组装一套完整的 husky lint-staged 提交流水线理论讲了不少下面直接给一套能抄作业的初始化流程。以一个前端项目为例同时加入 ESLint、Prettier、Stylelint 和 commitlint让 pre-commit 管代码规范commit-msg 管提交信息格式。4.1 环境准备与脚手架初始化首先确保项目是 git 仓库git init然后安装依赖。以 npm 为例npm install -D husky lint-staged eslint prettier stylelint commitlint commitlint/cli commitlint/config-conventional初始化 husky。新版直接npx husky init这句话会做几件事创建.husky/目录、写入.husky/pre-commit示例、在 package.json 里添加prepare脚本如果还没有的话。如果你希望更精确也可以手工走一遍npm pkg set scripts.preparehusky install npm run prepare npx husky add .husky/pre-commit npx lint-stagedhusky add其实就是创建 hook 文件并写入一行命令。注意它要求第一行有 shebanghusky 会自动补#!/usr/bin/env sh。我见过很多人手动创建.husky/pre-commit时漏了这行导致 hook 无法被正确执行。4.2 eslint prettier stylelint commitlint 的组合配置把.husky/pre-commit设置为npx lint-staged然后在package.json里配置{ lint-staged: { *.{js,jsx,ts,tsx,vue}: [ eslint --fix, prettier --write ], *.{css,scss,less,vue}: [ stylelint --fix, prettier --write ], *.{json,md,html,yml,yaml}: [ prettier --write ] } }这里有个常见的顺序选择先 eslint 还是先 prettier通常先跑 eslint --fix 再跑 prettier --write。因为 prettier 负责格式统一它在修复后不会破坏 eslint 规则而 eslint 的 fix 可能会改变一些格式放到前面更接近人工拼好后交给 formatter 统一的逻辑。如果你项目里同时用eslint-config-prettier关闭冲突规则那么两者怎么跑都不会有语法冲突。再添加 commitlint hooknpx husky add .husky/commit-msg npx --no-install commitlint --edit \$1注意$1需要转义因为它是 commit 临时消息文件的路径。commitlint 的校验规则配置在commitlint.config.jsmodule.exports { extends: [commitlint/config-conventional] };这样pre-commit负责代码质量commit-msg负责提交信息格式两个 hook 职责清晰互不干扰。4.3 package.json 里的 scripts 设计工程化项目建议把检查命令封装成 scriptslint-staged 里的命令可以直接复用 scripts也能直接写二进制。我习惯在 package.json 里定义{ scripts: { lint: eslint . --ext .js,.ts,.vue, format: prettier --write ., lint:fix: eslint . --fix --ext .js,.ts,.vue, typecheck: tsc --noEmit, prepare: husky install } }但注意lint-staged 里不要直接写npm run lint。为什么因为 lint-staged 需要把文件列表传给具体工具才能只处理暂存文件而你npm run lint里的eslint .是扫描全仓库这样就违背了 lint-staged 的初衷。lint-staged 的工作方式是把匹配到的文件路径作为参数追加到命令后面所以命令应该是eslint --fix而不是eslint .。在 TypeScript 项目里很多人纠结要不要在 pre-commit 里做类型检查。lint-staged 文档里的一个经典方案是{ src/**/*.{ts,tsx}: () tsc --noEmit -p tsconfig.json }注意这里函数返回的是单条命令没有把文件列表拼进去相当于在 pre-commit 里跑一次全量类型检查。这在中小项目没问题但大项目会慢。想只对暂存文件做类型检查很别扭因为类型检查本质上是项目级的操作。所以我的经验是如果团队 CI 会跑 typecheck本地 pre-commit 就不要再阻塞如果希望本地尽快暴露类型错误就接受一点全量 typecheck 的耗时把它塞进 pre-commit。二选一别两边都堵。5. 真实踩坑记录错误处理、忽略文件与跨平台问题再完美的配置也架不住真实环境的复杂性。下面这些坑几乎每个项目都会遇到我把排查链路写出来大家可以照方抓药。5.1 文件没有被 lint 的原因排查有同学反馈我明明配了 pre-commit 也看到 lint-staged 在跑但某几个文件没被 lint。逐层排查很重要。首先确认文件确实进了暂存区。lint-staged 只处理git add过的文件如果文件只是改了没 add它不可能去检查。这是因为它的设计原则是只检查即将提交的东西。于是要git add改过的文件再尝试 commit。其次看 glob 是否匹配。比如你改了config/settings.ts配置写的是*.js: [...]那当然不会匹配。用npx lint-staged --verbose可以看到实际匹配到的文件列表非常直观。接着检查忽略配置。ESLint 默认会读取.eslintignorePrettier 默认读取.prettierignore。假如你的 prettier 配置里忽略了dist/**lint-staged 匹配到dist/xx.js后执行 prettierprettier 会直接跳过不做输出看起来就是没有处理。如果你想强制 lint在配置中递交给工具的路径是绝对的工具仍会受自身 ignore 配置影响所以要么调整 ignore 规则要么在命令参数里加--no-ignore但通常不建议这样做。最后确认命令本身是否执行成功。如果 eslint 命令先失败后面的 prettier 可能不会执行默认任一命令失败会中断并阻止提交。你可以让 lint-staged 在 debug 模式下运行npx lint-staged --debug它会把每次读取的暂存文件列表、匹配结果、命令执行状态全部打印出来。5.2 Windows 下命令执行异常Windows 玩家最常见的两个报错一个是npx: command not found另一个是. is not recognized as an internal or external command。原因在于 husky 的 hook 文件是用 shell 脚本写的Windows 上执行时默认走 Git Bash 的 shell。Git Bash 能识别npx但如果你没有把 Node 的全局 bin 目录放进 PATH或者你是通过 NVM、fnm之类装的 NodeGit Bash 可能找不到 npx。常见的解决办法是在.husky/pre-commit开头手动导出 PATH。比如#!/usr/bin/env sh export PATH$HOME/.nvm/versions/node/v20.11.0/bin:$PATH但这没法让整个团队统一。更好一点的方式是把 Node 目录写成一个公用的脚本放到.husky/_目录里然后 hook 里source .husky/_/path.sh。还有一种思路是不要去 shell 里找 npx而是直接调用本地二进制#!/usr/bin/env sh . $(dirname $0)/_/husky.sh node_modules/.bin/lint-staged但这样绕过了 npx 的版本查找逻辑也有她自己的坑。我更推荐的做法是保持命令简单且让 husky 自己生成的 sh 头正常工作。如果在 Windows 上遇到路径问题可以把 hook 里的命令改成npm exec lint-stagednpm 会自行解析 PATH往往比npx更稳。另一个细腻的坑是行尾符。如果项目 git 配置了core.autocrlftruehook 文件在 checkout 时可能被转成 CRLF 导致 shell 无法执行。建议在项目根目录加.gitattributes.husky/** linguist-languageShell .husky/* text eollf确保 husky 目录在 Windows 上保持 LF。如果不放心可以git config core.autocrlf false后将 hooks 重写一遍再提交。5.3 与 CI 的配合commit 前拦截与管道复用很多团队本地配了 husky lint-staged但在 CI 上又跑一套不同的 lint 命令导致本地能过、CI 挂。其实正确思路应该是本地 hook 做快速拦截CI 做完整把关。比如本地 lint-staged 只检查暂存文件CI 可以做全量 lint 和 typecheck。这个分工是合理的但两者之间的规则集eslint config、prettier config、commitlint config必须同一套。你可以把配置抽成同一份配置文件CI 只执行npm run lint本地 hook 复用同一个 eslint 命令只是传入文件列表不同。这样就不会出现本地用相对宽松的配置CI 用严格配置这种双标。另外要注意lint-staged在 CI 上一般不需要跑。CI 是从 git 拉下来的完整仓库没有暂存区概念。如果非要在 CI 模拟 pre-commit可以用--diffHEAD~1来检查最近一次提交的文件列表但这通常是不必要的复杂化。我的建议是 CI 专注于全量校验本地 hook 专注高频反馈。还有个小经验pre-push 这个 hook 也值得加。pre-commit 只校验暂存区但某些同学会绕过它比如git commit --no-verify。如果你希望防得牢一点可以在.husky/pre-push里写npx lint-staged --diff HEAD~1或者直接跑全量 lint。不过要看团队是否接受 push 前的等待时间。我个人更倾向于信任 pre-commit 加上 code reviewpre-push 留给 CI 去把守不要层层设卡把人惹毛。6. 写到最后一些个人用的顺手技巧这几年的工程化落地经验里我最想强调的一点是工具链的配置一定要保持最小惊讶原则。不要在一个 hook 里堆太多命令不要把 lint-staged 的 glob 写得太花哨。真正能让团队长期执行的永远是那条一秒钟就跑完、改坏了会立刻报错的简单命令。我自己的习惯是在.husky/pre-commit里只放一句npx lint-staged把所有的文件类型和命令交给 lint-staged 统一管理。当团队里有人想加新规则时只需要改 package.json 或者lint-staged.config.js不用去动 shell 脚本。另一个技巧是把 husky 的自检脚本放在pre-commit中npx --no-install husky --help /dev/null 21 || (echo husky not ready, run npm install first exit 1)不过实际项目中很少这样写因为prepare脚本已经保证了husky install在装依赖时执行。真到了需要排查的时候我都是先跑npx husky init git config core.hooksPath ls -la .husky/三步确认环境再谈别的。最后分享一个我踩过不少次才悟出来的配置如果有多个工具同时处理同一批文件不要让两个命令都同时写文件。比如 esbuild-era 的人习惯prettier --write之后再跑eslint --fix两个工具都改文件lint-staged 自动 add 时会因为文件内容不断变化而多跑几轮。合理的做法是尽量让 prettier 收尾因为它最尊重配置、输出稳定。如果 eslint 的 fix 和 prettier 顺序相反有时会产生需要 commit 两次才能稳定下来的情况。这倒不是 bug而是两个工具对格式化决策的迭代关系不同。工具链的价值不在于把 hook 配得多全而在于让提交代码这件事变成一个自动化的、无感的动作。Husky 和 lint-staged 这对搭档本质上是把人工记忆强制变成了流程防线。你可以放心地把精力放在写业务上剩下那些回车键前面的检查交给它们就好。
返回列表