首页 / 资讯中心 / 文章详情

企业级Monorepo下的Husky 9与lint-staged质量门禁实战

企业级Monorepo下的Husky 9与lint-staged质量门禁实战 ★ FEATURED ARTICLE
1. 为什么企业级 Monorepo 一定需要 Husky 9 lint-staged这两年只要做前端基建几乎绕不开 Monorepo 这个词。我做团队基建时把原本散落的多个前端项目统一收进一个 Monorepo 仓库后最头疼的反而不再是依赖管理或构建缓存而是怎么保证每个人提交上来的代码质量一致。三个人协作时靠口头约定还行到了十人、二十人的规模就必须靠工具在提交那一刻把质量关卡住。这就是 Husky 9 和 lint-staged 在 Monorepo 工程化模板里不可替代的原因。很多同学对这两个工具的认知还停留在给 Git 加个钩子提交前自动格式化一下但真正落到企业级 Monorepo 场景要解决的问题远比这复杂一套代码仓库里有多个子包每个子包可能用不同的 lint 规则提交时只改了其中一个包的代码总不能把全仓库都 lint 一遍团队成员水平参差不齐不能指望每个人自觉去跑pnpm lint。Husky 9 负责在 Git 事件commit、push 等发生时触发钩子脚本lint-staged 负责精准定位暂存区文件并执行对应的质量检查命令两者配合才能在提交前最后一道防线上做到既严又稳。这篇内容我按自己实际搭建企业级 Monorepo 工程化模板的完整路径来写覆盖从初始化 pnpm workspace 到 Husky 9 配置、lint-staged 落地、commitlint 提交规范、CI 联动以及我在真实项目中踩过的各种坑。适合正在规划 Monorepo 架构、想给团队落地 Git 提交质量门禁的前端工程师参考哪怕你之前完全没接触过 Husky 和 lint-staged按这个流程走一遍也能直接跑通。1.1 Monorepo 带来的质量管控新挑战单仓库Single Repo时代一个项目一个仓库Git hooks 只管好自己那一亩三分地就行。但 Monorepo 架构把多个项目、公共包、工具链放进同一个 Git 仓库问题立刻不一样了。第一个问题是作用域爆炸。假设仓库里有packages/ui、packages/utils、apps/web、apps/admin四个子包你改了packages/utils/src/format.ts这个文件跑全量 lint 会检查几百个文件耗时可能从几秒直接飙到几十秒。实际上你只需要检查这一个改动的文件。lint-staged 的核心价值就是只对git diff --cached里出现的文件执行命令从机制上避免全量检查。第二个问题是规则冲突。Monorepo 里的不同子包可能承接不同技术栈的业务有的是 React TypeScript有的可能是 Vuelint 规则和格式化风格往往存在差异。如果 Git hooks 里写死一条全局 lint 命令要么某个子包永远报错要么大家被迫迁就最宽松的规则。更好的做法是在 lint-staged 配置里让每个子包各自管理自己的 lint 脚本Husky 钩子只负责触发具体检查逻辑交给对应包来处理。第三个问题是提交粒度和发布流程耦合。Monorepo 常配合 Changesets 或 Semantic Release 做自动版本发布提交信息commit message的规范性直接影响 changelog 生成和版本号计算。所以 commit-msg 钩子的校验在 Monorepo 里比单仓库更重要——feat(ui): add button component和fix: update style生成的 changelog 完全不一样。这意味着 Husky 不仅要挂 pre-commit还要挂 commit-msg形成完整的提交前置检查链。1.2 Husky 9 相比旧版的破坏性变化Husky 这个工具从 v4 到 v9 经历了非常大的变化。如果你之前用过.huskyrc.json或husky.config.js那种写法到了 v9 会发现完全不认识了。Husky v4 时代配置写在 package.json 的husky字段里通过husky install把钩子安装到.git/hooks目录。v5 之后作者改变了设计思路不再推荐在 package.json 里写配置而是直接在仓库根目录维护.husky/目录里面放的是真正会执行的 shell 脚本文件。v9 更是彻底移除了husky install命令安装和初始化方式变成了pnpm add -D husky npx husky init初始化完成后.husky/目录下会生成pre-commit文件同时 package.json 里自动加上prepare: husky脚本。这个prepare脚本会在执行pnpm install时自动运行从而把core.hooksPath指向.husky/。这样做的好处非常明显不需要再手动维护.git/hooks下的脚本钩子文件直接进 Git 仓库团队每个人 clone 下来执行一次pnpm install就自动生效完全不需要额外配置。注意在 pnpm 环境下如果项目的根目录.npmrc里设置了ignore-scriptstrue那prepare脚本不会执行Husky 钩子也不会生效。这是我在实际项目里遇到过的第一个坑后面排查部分会细说。Husky 9 的另一个变化是脚本文件本身不再需要.git后缀也不再要求第一行必须是#!/bin/sh。生成出来的pre-commit文件默认内容就是pnpm test这种极简形式你可以根据需求任意修改。这种文件即逻辑的设计对 Monorepo 来说非常友好因为你可以针对不同的钩子场景自由编排命令。2. 从零初始化 Monorepo 工程化模板的基础设施在接入 Husky 9 和 lint-staged 之前先把 Monorepo 的骨架搭好。这里我选用pnpm workspace作为包管理方案这是目前企业级 Monorepo 的主流选择没有之一。2.1 包管理器选型为什么是 pnpm workspace对比 npm、yarn classic、yarn berry 和 pnpm单从 Monorepo 支持度来看pnpm 有机制上的优势。pnpm 天然支持按项目维度管理依赖每个包都有独立的node_modules通过硬链接 符号链接的方式共享同一份依赖内容既避免了 npm 那种幽灵依赖问题package.json 里没声明的包也能被引用又极大节省了磁盘空间和安装时间。在我搭建的企业级模板里根目录/只放开发依赖和公共配置脚本业务代码都放在apps/下公共代码放在packages/下。依赖安装统一用pnpm install子包之间互相引用用workspace:*协议比如packages/ui的 package.json 里声明{ name: scope/ui, version: 0.0.0, main: src/index.ts, dependencies: { scope/utils: workspace:* } }这种声明方式让 pnpm 在安装依赖时把本仓库内的包链接起来开发时本地代码改了立即生效不需要手动npm link。发布时workspace:*协议会被替换成真实版本号非常方便。初始化 Monorepo 骨架的命令序列如下mkdir my-monorepo cd my-monorepo pnpm init然后创建pnpm-workspace.yaml文件内容packages: - apps/* - packages/*这个文件告诉 pnpmapps和packages下的每个子目录都是独立包。接下来在根目录 package.json 里补充脚本和基础配置把private: true加上避免误发布到 npm 仓库。2.2 搭建基础目录结构与包配置一个结构清晰的企业级 Monorepo 目录至少应该包含这几层my-monorepo/ ├── .husky/ # Git hooks 目录Husky │ ├── pre-commit # 提交前钩子 │ └── commit-msg # 提交信息钩子 ├── apps/ │ ├── web/ # 前端应用 │ └── admin/ # 管理后台 ├── packages/ │ ├── ui/ # 公共组件库 │ ├── utils/ # 工具函数 │ └── config/ # 共享配置eslint/tsconfig 等 ├── .eslintrc.cjs ├── .prettierrc.cjs ├── .commitlintrc.cjs ├── package.json ├── pnpm-workspace.yaml └── tsconfig.base.json这里我在packages/config里放共享配置是为了让不同子包的 lint 规则可以继承同一份基础规则再各自覆盖业务特殊配置。比如packages/config/eslint-base.js导出基础 eslint 配置apps/web/.eslintrc.cjs里通过extends引用它。这样当 lint-staged 执行某个子包的 lint 命令时用的是该子包自己的配置不会出现规则冲突。子包的写法可以精简比如packages/utils/package.json{ name: scope/utils, version: 0.0.0, main: src/index.ts, scripts: { lint: eslint . --ext .ts, format: prettier --write . } }整个仓库里每个包都自己定义 lint 脚本和格式脚本根目录的 Husky 钩子只负责按文件路径精准分发。3. Husky 9 lint-staged 集成实战骨架搭好之后进入重头戏安装配置 Husky 9 和 lint-staged。这一节我按实际操作的顺序逐步走每一步都说明为什么要这么做以及对应的效果。3.1 核心依赖安装与版本说明在项目根目录执行pnpm add -D husky lint-staged以我写这篇内容时的版本为例Husky 主版本是 9.xlint-staged 主版本是 15.x。这里有个需要特别提醒的如果你用的是 lint-staged 15它要求 Node.js 版本不低于 18.12.0。现在的企业级项目一般 Node 20问题不大但如果团队里还有老项目残留先确认 Node 版本再装。安装完成后立刻执行npx husky init这个命令会在项目根目录生成.husky/目录并自动创建一个pre-commit文件。它还会修改 package.json加上{ scripts: { prepare: husky } }这个prepare脚本的意义我在前面提过团队其他成员 clone 仓库后执行pnpm installHusky 钩子就自动激活。不需要每人手动跑npx husky init。3.2 初始化 Husky 9 的正确姿势与钩子脚本编写初始化之后打开.husky/pre-commit文件默认内容一般是pnpm test这里我们要把它改成真正适合 Monorepo 的提交前检查逻辑。我的推荐配置是pnpm lint-staged没错就是这么简单。pre-commit 钩子只负责调用 lint-staged具体的文件筛选、命令执行全部交给 lint-staged 去处理。这种职责单一的做法让钩子脚本本身几乎不需要维护所有变更集中在 lint-staged 配置里。然后创建 commit-msg 钩子用来校验提交信息是否符合规范npx husky add .husky/commit-msg npx --no -- commitlint --edit $1如果当前 Husky 版本支持直接用命令创建也可以写好内容后手动创建文件。.husky/commit-msg文件内容npx --no -- commitlint --edit $1这里提一个小知识点npx --no -- commitlint里的--no表示不自动安装未安装的包如果本地 node_modules 里没有 commitlint 就直接报错避免在钩子执行时意外联网安装依赖提升可复现性。创建完成后用chmod x .husky/pre-commit .husky/commit-msg给钩子文件加执行权限macOS/Linux 下必要Windows 下 Git Bash 一般自动处理。提示.husky/目录生成的钩子文件必须提交到 Git 仓库这样所有成员才能共享一致的提交检查逻辑。不要把它加进.gitignore。3.3 配置 lint-staged 实现提交前自动检查lint-staged 的作用域是暂存区staged files它会读取 git 暂存区里匹配指定模式的文件然后针对这些文件执行配置的命令。在 Monorepo 场景下我们要考虑的是如何让 lint-staged 恰好对被提交的那个子包执行检查而不是对全仓库跑一遍。lint-staged 的配置文件我习惯放在根目录用.lintstagedrc.cjs或 package.json 里的lint-staged字段都可以。下面是推荐配置// .lintstagedrc.cjs module.exports { *.{ts,tsx,js,jsx}: [eslint --fix, prettier --write], *.{json,css,scss,md}: [prettier --write], *.vue: [eslint --fix, prettier --write] };这个配置的意思是当暂存区里有.ts、.tsx、.js、.jsx文件时依次执行eslint --fix和prettier --write有.json、.css、.scss、.md文件时执行prettier --write。lint-staged 会把匹配到的文件路径拼到命令后面所以eslint --fix实际执行的是eslint --fix src/index.ts src/foo.ts ...只检查刚才提交的文件。在 Monorepo 场景下如果你的子包各自有自己的 eslint 配置和 lint 脚本还有一个更精细的配置方式按目录匹配调用对应包自己的命令。比如module.exports { packages/utils/**/*.{ts,tsx}: [pnpm --filter scope/utils lint], apps/web/**/*.{ts,tsx}: [pnpm --filter scope/web lint] };这种方式更符合企业级 Monorepo 的诉求因为每个子包的 lint 规则独立演进互不影响。不过要注意一点pnpm --filter命令会把 lint-staged 传进来的文件路径忽略掉它执行的是整个包的 lint 脚本而不是只 lint 暂存文件。如果你的仓库很大全包 lint 太慢那就回到第一种全局配置方式让 eslint 自己去匹配文件路径并确保 lint-staged 把文件路径传给 eslint 后eslint 能正确加载对应子包的配置。lint-staged 在 Monorepo 下的一个关键行为必须了解它默认在 git 仓库根目录执行命令。假设你的 eslint 配置层面存在路径解析依赖比如tsconfig.eslint.json通过extends引用相对路径那这个路径是相对 eslint 配置所在目录解析的不是相对当前工作目录所以大多数情况下没有问题。但如果你的子包有特殊的解析逻辑建议在 lint-staged 配置里用函数形式动态指定module.exports { *.{ts,tsx}: (filenames) { return eslint --fix ${filenames.join( )}; } };函数形式让你可以拿到文件列表做任意组合比如限制一次提交的文件数量、远程调用某些工具等。我还强烈建议在 lint-staged 里加一个兜底命令防止暂存区里有改动但没匹配到任何规则module.exports { *: [git add] };不过这行命令只在你需要让 lint-staged 修复后的文件重新回到暂存区时才需要。实际上 lint-staged 默认会把命令执行后变更的文件重新git add回暂存区默认行为所以不是必须的。如果你用了--no-stash之类的参数或者 lint-staged 版本行为有变化再手动处理。4. 常见问题与排查技巧实录Husky 9 和 lint-staged 配置起来不难但实际跑起来会出现各种幺蛾子。我把自己在落地过程中遇到的高频问题和排查思路整理出来按现象—原因—解决的方式记录方便你直接对号入座。4.1 Husky 9 不生效prepare 脚本被跳过和钩子权限问题现象 1配置完 Husky 后提交代码时钩子完全没有触发git commit直接成功没有任何 lint 动作。排查思路先确认.husky/目录是否存在且里面有pre-commit文件。再执行git config core.hooksPath如果输出为空或不是.husky说明 hooks 路径没有设置。执行pnpm prepare手动跑一次看有没有报错。最常见的原因是.npmrc里设置了ignore-scriptstrue。很多企业级项目为了安全性会在全局或项目级别的.npmrc里关掉 npm script 执行这会导致prepare: husky不运行Husky 无法注册 hooks。解决方法是项目根目录的.npmrc里显式设置ignore-scriptsfalse或者直接用pnpm approve-builds之类的命令允许 husky 执行。在 pnpm 10 里还有pnpm.onlyBuiltDependencies的配置项。现象 2钩子文件存在手动执行.husky/pre-commit也能跑但 git commit 时仍然不触发。这通常是权限问题。macOS/Linux 下钩子文件没有执行权限Git 不会执行它。执行chmod x .husky/pre-commit .husky/commit-msgWindows 下如果用 Git Bash一般不需要单独处理但如果你在 Windows 上使用 PowerShell 或 CMD 运行 gitHusky 可能无法在钩子脚本里正确执行 pnpm 命令。建议团队统一推荐 Git Bash 或 WSL 来操作。现象 3钩子执行时报command not found: pnpm。这是最常见的路径问题。Git hooks 环境变量继承自 Git 进程不一定会带 node_modules/.bin 到 PATH。解决方法是执行命令用npx或pnpm exec替代裸命令或者在钩子文件顶部手动导出 PATH#!/bin/sh export PATH/path/to/node_modules/.bin:$PATH pnpm lint-staged在企业级模板里我更推荐用pnpm exec lint-staged或者直接在 pre-commit 里写npx lint-staged减少环境差异。4.2 lint-staged 在 Monorepo 下只检查当前包路径与命令的边界lint-staged 在 Monorepo 下最容易遇到的问题有两个第一个是工作目录问题。lint-staged 默认在 git 根目录执行命令。如果你的命令是cd packages/utils eslint --fix那可能有问题因为 lint-staged 传给命令的文件路径是相对于 git 根目录的。解决方式是用函数形式对文件路径做预处理const { execSync } require(child_process); module.exports { packages/utils/**/*.{ts,tsx}: (filenames) { const files filenames.map((f) f.replace(packages/utils/, )); return cd packages/utils eslint --fix ${files.join( )}; } };这种方式比较绕除非必要我一般不建议在 lint-staged 里切目录而是让 eslint 使用--resolve-plugins-relative-to或直接统一在根目录配置 eslint 规则。第二个是规则继承问题。如果每个子包有自己的 eslint 配置且互相独立lint-staged 在根目录跑eslint --fix packages/utils/src/format.ts时eslint 会从目标文件所在目录向上查找最近的.eslintrc。只要你的子包都有自己的.eslintrc这个查找机制能正确加载对应配置不会误用根目录规则。关键点子包的 eslint 配置文件必须放在子包目录内并且不能依赖当前工作目录这个隐式前提。我维护的模板里子包packages/utils/.eslintrc.cjs长这样module.exports { root: true, extends: [scope/config/eslint-base] };加了root: true是为了阻止 eslint 继续向上查找配置文件确保每个子包用自己这套规则不会和根目录混淆。这一步在 Monorepo 里非常重要很多人 lint 结果时对时错就是这里出了问题。还有一个坑lint-staged 15 在 pnpm 环境下如果涉及多个子包的文件同时提交比如一个人一次改了packages/utils和apps/web的文件lint-staged 匹配规则会各自匹配然后分别执行命令。但如果你的规则写的是全局*.{ts,tsx}那所有.ts文件会合并到一次 eslint 调用里执行。这时如果apps/web和packages/utils的 eslint 配置冲突eslint 会因为找不到唯一配置而报错。这也是为什么我建议在 Monorepo 里用细粒度的路径匹配规则。4.3 commitlint 与 Husky commit-msg 钩子的联动问题commitlint 是用来校验 commit message 是否符合 Conventional Commits 规范的工具。在 Monorepo 里它和 Changesets 强配合因为 changelog 的生成完全依赖提交信息里的 type 和 scope。安装方式pnpm add -D commitlint/cli commitlint/config-conventional然后在根目录创建.commitlintrc.cjsmodule.exports { extends: [commitlint/config-conventional], rules: { type-enum: [ 2, always, [feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert] ], scope-enum: [ 2, always, [web, admin, ui, utils, config, global] ] } };: scope-enum规则强制提交时要说明影响范围。比如feat(web): add login page里的web必须是在枚举列表里。这在 Monorepo 里很有用能让你从 git log 里快速定位哪个包的哪个功能被改过。联动可能出现的问题commit-msg 钩子里执行npx commitlint --edit $1如果 commitlint 配置里报错找不到 config大概率是commitlint/config-conventional没有安装到根目录 node_modules。pnpm 的严格依赖隔离机制下子包里安装的依赖默认不能直接被根目录钩子 npx 调用所以 commitlint 相关依赖必须安装在仓库根目录。5. 企业级实践的进阶配置与协作规范基础流程通了之后我再补充一些真正让模板企业级的细节。这些内容不是必须的但没有它们一个工程化模板只能算能用离好用还有距离。5.1 Pre-commit 阶段的性能优化与命令编排lint-staged 默认串行执行命令如果文件多、规则重pre-commit 阶段耗时可能很恐怖。团队里如果有几次提交等了一分钟很快大家就会想各种方式绕过 hooks。性能优化可以从三个方向入手第一减少 eslint 检查体积。在 eslint 配置里开启cache配合 lint-staged 使用。eslint 会生成一个缓存文件没有被修改的文件直接跳过检查速度提升非常明显。module.exports { *.{ts,tsx}: [eslint --fix --cache, prettier --write] };第二把格式化任务和 lint 任务解耦。eslint 的--fix和 prettier 的职责有一部分重叠都做代码风格修复。如果项目里已经用 prettier 做格式化eslint 里可以通过eslint-config-prettier关闭与 prettier 冲突的规则避免两者互相打架。lint-staged 命令顺序上先跑 eslint --fix再跑 prettier --write能减少最终文件被反复改动的概率。第三条件性跳过 heavy 命令。有些 monorepo 包含非常大的生成文件或第三方代码但通常不会出现在暂存区里。你可以在 lint-staged 配置里给大文件模式走一个轻量级的 prettier 检查而不是 eslint 全量检查比如module.exports { *.min.js: [prettier --check], *.{ts,tsx}: [eslint --fix --cache, prettier --write] };这样只对压缩文件做格式化校验而不是重写避免把第三方混淆代码改得不可读。5.2 CI 流水线中的质量门禁如何与本地 hooks 协同本地 Git hooks 只能约束开发者的本地提交但总有意外情况有人本地跳过了 hooksgit commit --no-verify、有人从 IDE 提交时钩子没有正确加载、有人在自己分支上把代码提交得很随意然后在合并到主干时才出问题。所以企业级的质量门禁一定不能只靠本地 hooksCI 里必须有一层兜底。在 CI 流水线中我建议跑这几个检查和本地 hooks 形成互补检查项本地 hooksCI 流水线目的ESLint全量可选lint-staged 只查暂存文件必选全量检查防止本地漏检Prettier 格式检查自动修复prettier --check统一风格commitlint 校验commit-msg 钩子对 PR 的提交信息检查保证合并信息合规TypeScript 类型检查可选如果项目大必选防止类型错误单元测试可选必选回归保障CI 里的 lint 不再用 lint-staged因为 CI 是拉取整个分支的代码没有暂存区概念。CI 用全量 lint 和全量类型检查虽然慢一点但这是对合并到主干代码的最终防线。这里有个经验技巧本地 hooks 做增量、CI 做全量。本地每次都全量检查会让开发者崩溃但 CI 里全量检查是合理的成本。所以 lint-staged 的配置只服务于本地体验CI 配置文件比如.github/workflows/ci.yml单独写全量检查步骤两者进度分开互不干扰。5.3 让团队真正用起来模板落地与文档化很多团队不是没有工具而是工具落地后没人愿意配合。工程化模板做得再完美如果团队用起来别扭就会被绕过。我的落地经验是把 hooks 脚本做到零心智负担。开发者不需要理解 Husky 的原理也不需要知道 lint-staged 的配置他们只需要正常git commit看到检查失败就根据提示改代码。所以 lint-staged 配置里的命令输出要清晰比如在 eslint 命令后面加--format stylish报错信息直接定位到文件和规则而不是只有一行 Error: ESLint failed。给团队成员一份 5 分钟上手文档。文档里明确写清楚clone 代码后需要执行pnpm install不需要任何额外初始化提交信息必须符合 Conventional Commits 规范如果提交被拦截根据提示修改代码或提交信息禁止使用--no-verify绕过检查通过 Code Review 纪律约束保留一个手动全民检查出口。在极特殊场景下比如线上事故修复、某个文件确实需要暂时绕过 lint本地可以允许--no-verify但前提是这个文件必须在下一次提交里补齐修复。企业级规范不应该是冷冰冰的强制执行而是给团队留一个应急预案的窗口同时通过 CI 兜底防止真的漏到主干。这种严进宽出的组合执行阻力最小质量也有保障。写在最后的一点个人体会Monorepo 工程化模板搭好容易真正难的是让团队形成肌肉记忆。我从第一次在项目里配置 Husky 9 到现在前后重构过三个版本最大的体会是工具链的设计一定要围绕人的体验来展开。lint-staged 把检查范围缩到最小、Husky 把执行时机卡在最前、commitlint 把协作语言统一起来这些都不是炫技而是为了让开发者把精力放在写代码本身而不是和质量工具斗智斗勇。如果你也想在企业级项目里落地这套模板我建议先在小范围试运行一两个迭代观察团队提交时的反馈再逐步推广到全组。配置层面的细节可以随时按团队节奏调整但提交前必须过质量检查这条底线一旦定下来就不要轻易放松。
阅读完成 · 觉得有帮助?
咨询建站