企业级Monorepo工程化模板:Husky 9与lint-staged集成实践
发布时间:2026/9/19 21:18:09 锦皓数字建站

先说个我上个月真实踩过的坑。团队里接了个老项目典型的多仓库结构——一个公共组件库、两个业务前端、一个 Node 服务层各自独立仓库、独立依赖、独立 lint 规则。表面上看互不干扰挺美可真要升级一个公共依赖的时候那酸爽简直没法形容组件库改了接口签名两个业务方得手动跟进漏改一个就是线上事故新同事入职光配环境就得一上午各种重复的 eslint、prettier、husky 老配置散落在各个仓库里版本还不一致。后来我下定决心把所有东西迁移到 Monorepo 架构用 pnpm workspace 统一管理再配合一套标准的工程化模板把 Husky 9、lint-staged、commitlint 全部收敛到一起。这篇文章就把这套企业级 Monorepo 工程化模板的搭建过程完整记录下来重点放在 Husky 9 和 lint-staged 的集成上——这两块是我踩坑最多、也是网上资料最容易过时的地方。如果你是前端负责人、后端要牵头搞规范或者正在被多仓库的同步成本折磨这篇文章可以直接当作业抄。我会把每一处配置背后的原因、常见坑位和排查思路都讲清楚不是晒配置是真的能落地的方案。1. 为什么企业级项目需要 Monorepo 工程化模板1.1 多仓库的痛依赖割裂与同步成本我见过太多团队一开始觉得仓库分开干净结果项目越做越大的时候问题集中爆发。具体来说多仓库模式有四个绕不开的痛第一依赖版本漂移。A 仓库用的 lodash 还是 4.17 老版本B 仓库已经升到 5.x公共工具库在某次升级里改了行为两边表现完全不一致排查起来无从下手。第二跨仓库改动的原子性缺失。业务方改需求的时候可能需要同时改组件库和业务项目同一个功能拆到两个 PR、两个仓库review 和发布都得互相等效率极低。第三本地联调成本高。组件库要想在业务项目里看效果要么npm link要么发布 alpha 版本不管哪条路都绕圈。第四规范难以统一。每个仓库各自维护一套 lint、husky、commitlint出现配置分叉几乎是必然的。Monorepo 架构解决的就是这些问题。所有代码放在同一个仓库用统一工具链管理公共依赖提升到根目录子包之间通过 workspace 协议直接依赖本地源码。这样一来依赖版本只有一份跨包改动一个 commit 就能完成新成员 clone 一次代码、装一次依赖就能跑起全部项目。我把它理解成把散落的一堆乐高零件装进同一个盒子还配好了说明书。1.2 工程化模板不是套话而是团队的代码宪章说到工程化模板很多人第一反应是这不就是脚手架吗生成一堆配置文件而已。但企业级 Monorepo 模板的真正价值不在生成而在约束。我记得有本书里提过一个观点软件工程的核心是管理复杂度。在一个多人协作、多包并存的 Monorepo 里没有统一规范复杂度会以指数级增长。一套好的工程化模板至少要回答四个问题代码怎么写由 ESLint Prettier EditorConfig 决定包括缩进、引号、分号、代码规则。提交怎么写由 commitlint 决定commit message 必须符合 Conventional Commits 规范。什么时候检查由 Husky 9 注册的 Git Hooks 决定commit 之前要过 lint、测试、格式校验。检查哪些文件由 lint-staged 决定只针对暂存区里的增量文件不打扰存量代码。这四项组合在一起就形成了一条提交即检查、不符即拦截的流水线。开发者写代码的时候自由发挥但提交的那一刻规范站出来把关。这比在 code review 里逐行踩格式问题要高效得多。而且模板做成可复用的新项目 clone 下来直接改包名团队规范天然同步不会再出现你的 eslint 版本和我不一样这种窝心事。2. 环境准备与 Monorepo 骨架搭建2.1 技术选型pnpm workspace 为什么比 npm/yarn 更合适Monorepo 的底层依赖管理我选了 pnpm主要原因有三条不是跟风是被现实打磨出来的。第一依赖安装速度和磁盘占用。pnpm 使用全局内容寻址存储不同项目的相同版本依赖只保留一份物理副本通过硬链接挂载到 node_modules。实测一个包含 8 个包的项目pnpm 安装时间大约是 npm 的三分之一磁盘占用能省一半多。第二幽灵依赖的规避。npm 和 yarn 传统模式会把所有包平铺到 node_modules 顶层导致一个包能 require 到 package.json 里根本都没声明过的依赖——这就是幽灵依赖。pnpm 的严格符号链接结构要求每个包只能访问自己声明的依赖不符合就报错。这一点在企业级维护里太重要了它逼着开发者把依赖交代清楚减少本地能跑、CI 挂了的玄学问题。第三workspace 协议支持。pnpm 原生支持workspace:前缀比如shared/ui: workspace:*它会自动解析成本地包的当前版本不会错误地去 npm registry 拉一份同名远程包。当然npm 和 yarn 也在做 workspace 支持但 pnpm 在 Monorepo 场景下生态更成熟问题更少。这里多说一句选 pnpm 不是因为它完美而是因为它现阶段最省心。2.2 初始化工程目录与根 package.json 配置假设你的项目名叫enterprise-platform先在根目录建pnpm-workspace.yamlpackages: - packages/* - apps/* - docs这个文件告诉 pnpm 哪些目录属于 workspace。我习惯分成packages给其他包用的公共库和apps可部署的业务应用docs单独放文档站点。从语义上把库和应用区分开后续配置 lint、构建、发布规则时可以针对性设置。然后初始化根package.json关键字段如下{ name: enterprise-platform, private: true, packageManager: pnpm9.15.0, engines: { node: 20.0.0 }, scripts: { build: pnpm -r build, dev: pnpm -r --parallel dev, lint: eslint . --max-warnings0, format: prettier --write ., prepare: husky }, devDependencies: { husky: ^9.1.7, lint-staged: ^15.2.10, eslint: ^9.17.0, prettier: ^3.4.2, commitlint/cli: ^19.6.1, commitlint/config-conventional: ^19.6.0 } }有几个点需要特别解释一下private: true可以防止根包被意外发布到 registry。packageManager字段是给 corepack 识别用的。Husky 9 在安装时会读取这个字段判断当前包管理器如果没有它pnpm 安装阶段可能报Missing packageManager field警告。这个字段还能锁定包管理器版本避免同事用不同版本 pnpm 导致 lockfile 反复变。prepare: husky是 Husky 9 的生命周期脚本不是写错的 husky install。每次执行pnpm install后它会自动重建 Git Hooks保证新成员 clone 下来一装依赖钩子就生效。eslint . --max-warnings0是我给全量 lint 脚本加的严格模式限制理论上该把所有 warning 当 error 处理。但实际落地时可以渐进放开先保证新增代码没有 warning旧债分阶段清理。接着在根目录补一份.gitignore至少要忽略这些node_modules dist coverage .turbo pnpm-debug.log如果后面接了 Turborepo 或者 Nx 做任务编排.turbo这类缓存目录也要忽略免得把缓存提交进仓库。初始化完pnpm install装好依赖之后就可以进入正题配置 Husky 9 了。3. Husky 9 集成新一代 Git Hooks 管理方案3.1 Husky 9 的初始化流程与目录结构Husky 9 的初始化流程和 v8 之前完全不同这也是全网资料最容易误导人的地方。旧版本需要执行npm install husky --save-dev然后手动在 package.json 写prepare: husky install再通过npx husky add .husky/pre-commit npm test逐个添加钩子。Husky 9 官方推荐直接用husky initpnpm dlx husky init执行完这一步Husky 9 会做三件事创建.husky/目录。生成一个示例文件.husky/pre-commit默认内容是npm test。自动在根package.json写入prepare: husky。.husky/目录就是所有 Git Hooks 的存放位置每个文件名对应一个 hook 名内容是一段 shell 脚本。默认的pre-commit文件长这样npm test注意它没有#!/usr/bin/env sh之类的 shebangHusky 9 在初始化时会自动处理可执行权限和运行时环境不需要我们手动加。我见过很多人会画蛇添足加一堆source命令反而容易出问题。3.2 从 v8 迁移到 v9最关键的三个变化如果你的项目还在用 Husky 8升级到 9 之前务必了解三个关键变化第一husky install命令被移除了。旧版本在prepare脚本里写的是husky installv9 直接写husky即可。如果保留husky install安装时会直接报错找不到命令。第二初始化方式改为husky init。它会主动帮你写prepare脚本和.husky/pre-commit示例文件旧项目迁移时建议把所有旧的.husky/*文件检查一遍删掉过时的husky add生成的文件头信息。第三Husky 9 对core.hooksPath的处理更严格了。它会把 Git Hooks 目录指向.husky/如果项目里其他工具也在改core.hooksPath可能互相覆盖。检查方式git config core.hooksPath正常情况下输出应该是.husky。如果不是需要手动修正git config core.hooksPath .husky这个检查建议写进团队文档因为它在 Windows 环境下尤其容易出问题。我在公司 Windows 开发机上就遇到过明明装好了 husky但 commit 就是不触发的情况最后定位到是 git 全局配置里 hooksPath 被某个 GUI 工具改掉了。3.3 配置第一个 pre-commit 钩子把初始化生成的.husky/pre-commit内容改成pnpm lint-staged这是 lint-staged 的入口后面我们会把 lint-staged 配置成复杂的检查流。这里有个细节为什么在钩子里只写pnpm lint-staged而不直接写pnpm lint因为全量 lint 在大型 Monorepo 里太慢了。你提交一个 2 行的 bugfix却要把整个仓库几万行代码全过一遍 ESLint这种体验没人受得了。lint-staged 只处理暂存区里要提交的那几个文件秒级完成这才是增量检查的意义。.husky/pre-commit编辑好之后可以先用一个空 commit 测试钩子是否触发git add .husky/pre-commit git commit -m chore: 初始化 husky 钩子如果 lint-staged 没装好或者配置有问题这一步就会直接暴露。我建议大家在配置环节就层层验证不要一次性把全程配置完再 debug那样问题定位起来很难受。4. lint-staged 集成只处理暂存区的增量文件4.1 lint-staged 核心机制为什么不能直接跑全量 lint先解释一下 lint-staged 的原理。它做的事情可以拆成四步读取 Git 暂存区找到所有被 staged 的文件列表。拿这份文件列表和你的 glob 配置做匹配找出命中的文件。对每个匹配规则执行配置的那一串命令把文件路径作为参数传进去。命令执行成功后把可能被自动修复过的文件重新加入暂存区。所以 lint-staged 本质是一个暂存区文件分发器它不负责 lint只负责把文件交给 lint 工具。它的核心价值就是省时间——用增量检查代替全量检查让 git commit 这个动作不会变成漫长的构建任务。有人可能会问那我每次都手动跑eslint .不也一样吗差别在于第一全量检查会扫到大量与本次提交无关的历史代码出现问题会让提交者很懵第二全量检查慢一次几秒到几十秒写完代码还得等它跑完才能提交毫无幸福感第三手动跑的话没有任何强制力你没法保证每个人提交前都会执行而挂钩子到 pre-commit 后是强制的。lint-staged 把增量和强制两个特性都吃满了。4.2 企业级 lint-staged 配置文件写法lint-staged 支持在package.json里配置lint-staged字段也支持独立的lint-staged.config.js文件。对我来说Monorepo 根目录项目可能会越来越复杂配置文件独立出去更清晰所以我选择lint-staged.config.js// lint-staged.config.js module.exports { *.{js,jsx,ts,tsx}: [eslint --fix, prettier --write], *.{json,md,yaml,yml,css,scss}: [prettier --write], *.{vue}: [eslint --fix, prettier --write] };这里的关键点在于多个命令放在同一个数组里。lint-staged 对同一种 glob 模式命中的文件会按数组顺序依次执行每一条命令前一条命令修复过的文件会自动进入下一条命令的输入。这是常见的坑——很多人会写成*.{js,ts}: [eslint --fix], *.{js,ts}: [prettier --write]第二次出现的 key 会覆盖第一次导致后面的 prettier 永远不执行。正确写法就是把命令放进同一个数组。另外注意我没有把--fix参数去掉。ESLint 的--fix能把能自动修复的格式问题直接修掉不能修复的逻辑问题会报错lint-staged 捕捉到非零退出码后中断 commit。Prettier 的--write同理直接把文件格式化。这样设计很合理机器能改的机器改机器不能改的提交时拦住。4.3 Monorepo 场景下的路径与 ESLint 配置问题在 Monorepo 里跑 lint-staged有几种实际情况值得提前处理。场景一根目录和子包有独立的 ESLint 配置。最简单的方案是统一在根目录装 ESLint 和 Prettier根目录放一份 eslint.config.js 和 .prettierrc所有子包共用这一套配置。这样 lint-staged 在根目录执行eslint --fix读取根配置行为最可控。如果说某些子包确实需要特殊规则可以在根配置文件里按目录覆盖而不是每个包各挂一份配置。场景二确实需要按子包独立跑 lint这种情况可以用 lint-staged 的函数形式// lint-staged.config.js const { execSync } require(node:child_process); module.exports { packages/**/*.{ts,tsx}: (filenames) { const packages [...new Set(filenames.map((f) f.split(/).slice(0, 2).join(/)))]; return packages.map( (pkg) pnpm --filter ${pkg} exec eslint --fix ${filenames.filter((f) f.startsWith(pkg)).join( )} ); } };这段代码的意思是把所有命中packages/**/*.{ts,tsx}的文件按所属子包分组然后对每个子包分别执行它自己的 eslint。不过我不太建议一上来就搞这么复杂除非是真的有多个子包需要完全独立的 lint 规则。大多数企业项目用一套统一规则管理复杂度反而低。场景三新增或删除文件时lint-staged 拿不到文件内容。这个要特别注意如果文件是新增的untracked并且没有执行git addlint-staged 是看不到它的。所以一定要让团队养成git add后再 commit 的习惯。lint-staged 默认会处理 staged 文件但如果你用git commit -a这种自动暂存方式部分 IDE 的表现可能不同。最稳妥的做法显眼位置提醒好团队先 add 后 commit。5. 提交信息与代码风格的完整闭环5.1 commitlint 校验 commit-msgpre-commit 钩子只能保证代码质量但提交信息一团乱麻的话后面追溯需求、生成 changelog 都会非常痛苦。所以我在模板里还接入了 commitlint通过 commit-msg 钩子在提交信息写入之前拦截不符合 Conventional Commits 规范的提交。先安装依赖pnpm add -D commitlint/cli commitlint/config-conventional然后在根目录创建commitlint.config.js// commitlint.config.js module.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, [shared, ui, app, server, docs, root] ], subject-empty: [2, never], type-empty: [2, never] } };scope-enum是我在 Monorepo 场景下特别喜欢加的一条规则。每个 commit 必须标清楚影响范围比如fix(ui): 修复 Button 组件 loading 状态闪烁代码 review 的时候一眼就知道这个改动波及哪里。这个规范对大型 Monorepo 的 changelog 自动生成也极有价值。配套注册 commit-msg 钩子npx husky add .husky/commit-msg pnpm commitlint --edit \$1\执行完生成的.husky/commit-msg内容类似pnpm commitlint --edit $1注意如果你使用的是 pnpm不要在前面加npx直接pnpm commitlint即可。我在某些环境遇到过npx解析 commitlint 时选错了 registry 镜像的问题导致本地能跑、同事那边却报模块找不到。5.2 Prettier EditorConfig 统一代码风格代码风格统一是团队协作的地基。我见过最抓狂的画面一个文件里单引号和双引号混用、缩进一会儿 2 空格一会儿 4 空格用 IDE 格式化一下把整个文件的 diff 全带偏了。Prettier 就是用来终结这种撕扯的。根目录创建.prettierrc{ printWidth: 100, tabWidth: 2, useTabs: false, semi: true, singleQuote: true, trailingComma: es5 }再创建.prettierignore避免格式化 node_modules 或构建产物node_modules dist coverage pnpm-lock.yaml同时补一份.editorconfig照顾 Eclipse、VS Code 等编辑器的自动缩进识别root true [*] charset utf-8 indent_style space indent_size 2 end_of_line lf insert_final_newline true trim_trailing_whitespace true配好之后建议在.husky/pre-commit里依赖 lint-staged 的 prettier 命令前面已经配了这样提交时不需要手动跑格式化编辑器里保存时也会触发 ESLint 的自动修复和 Prettier 的格式化需要装对应 IDE 插件。最终效果就是不管谁来写提交到仓库的代码风格是一致的diff 干净、review 省心。5.3 package.json 脚本编排与全链路打通到这一步模板的主要组件都已经就位我习惯把它们串成一串脚本放在根package.json的 scripts 中{ scripts: { prepare: husky, lint: eslint . --max-warnings0, format: prettier --write ., precommit: lint-staged } }其中precommit这个自定义脚本在.husky/pre-commit里调用的是pnpm lint-staged不是pnpm precommit因为 lint-staged 需要保持在前台执行如果外面套一层pnpm再执行 lint-staged有时在进程信号传递上会有小毛病。直接写pnpm lint-staged少一层封装问题最少。到这里一次常规提交流程就变成这样git add . git commit -m feat(ui): add loading state to Button触发 pre-commit 钩子lint-staged 扫描暂存区文件ESLint 自动修复并校验 JS/TS/Vue 文件Prettier 自动格式化命中文件如果检查失败commit 被中止不会产生脏提交如果检查通过自动进入 commit-msg 钩子commitlint 校验提交信息格式非法格式同样被拦截整个流程是自动的、强制的不需要开发者记住跑什么命令。这就是工程化模板的价值所在。6. 实战踩坑与排查技巧6.1 常见问题速查表配置这套模板的过程中我在团队和社区里积累了不少典型问题直接整理成速查表方便各位对照现象可能原因排查思路commit 时钩子完全没反应core.hooksPath 被覆盖执行git config core.hooksPath看输出是否为.huskyprepare 脚本报 command not found: huskynode_modules 没装好或脚本写错检查pnpm install是否成功确认 prepare 内容是husky而非husky installlint-staged 跑起来但 ESLint 不检查glob 模式没匹配上文件检查 lint-staged 配置里的路径模式packages/**/*.ts和*.ts匹配范围完全不同同一组文件只有 prettier 生效配置了重复 key 的 glob确认 ESLint 和 Prettier 命令放在同一个数组里新 clone 下来钩子失效文件权限丢失执行chmod x .husky/pre-commit .husky/commit-msg或重新pnpm installWindows git-bash 下脚本报错shebang / 行尾符问题确保.husky/*是 LF 行尾不开自动 CRLF 转换6.2 Husky 钩子不生效的排查思路如果发现 git commit 的时候 Husky 的任何钩子都不触发按以下顺序排查第一步确认.husky/目录存在且有钩子文件。第二步检查git config core.hooksPath如果不是.husky先修正。第三步重跑一次pnpm install让 prepare 脚本重建钩子可以在终端观察输出。第四步用git hook run pre-commitGit 2.36 支持的命令手动测试钩子看能不能复现问题。第五步看.git/hooks目录有没有被其他工具干扰。这里分享一个我实际遇到过的情况同事用 SmartGit 提交代码SmartGit 默认绕过core.hooksPathHusky 钩子一次都没触发。这种 GUI 工具导致的静默绕过最隐蔽排查方式就是让团队统一用命令行提交或者统一指定钩子路径。真要说起来这也是企业级规范里必须明确的一条允许用什么客户端不允许用什么。6.3 TS 项目与 lint-staged 的交互问题在 TypeScript 的 Monorepo 里有一个非常经典的 TS ESLint 的坑eslint --fix可以修复大部分格式问题但如果项目启用了 type-aware 规则parserOptions.project临时把暂存区文件传给 eslint 时文件可能不在 tsconfig 的 include 范围内导致报解析错误。解决办法有几种。第一种在 ESLint flat config 里把parserOptions.project设成tsconfig.json同时用tsconfigRootDir: __dirname。第二种对 lint-staged 里传给 eslint 的命令做一层过滤只处理源文件不要把配置文件、生成文件等传入。第三种实在不行给该子包单独建一个tsconfig.eslint.json把需要 lint 的文件都 include 进去。{ extends: ./tsconfig.json, include: [src, tests, vitest.config.ts, eslint.config.js] }然后在 eslint.config.js 里用这个配置文件作为 parserOptions 的 project 路径。这种方式在大型 Monorepo 里最稳因为不同子包的 tsconfig 各有差异统一在一个 eslint 配置里跑 type-aware 规则非常容易出错。6.4 CI 环境下的钩子处理策略最后一个问题在 CI 环境里跑 lint 和测试的时候需要不需要执行 Git Hooks我的建议是CI 不需要跑 Husky因为 CI 是基于分支 push 触发本来就不走本地 commit 流程。但 CI 里要做一次全量 lint 和测试这是本地增量检查的兜底。所以在 CI 脚本里可以显式禁用 Husky避免 prepare 阶段意外报错HUSKY0 pnpm install --frozen-lockfile pnpm lint pnpm build pnpm testHUSKY0是 Husky 9 官方支持的跳过环境变量设置后 prepare 脚本不会真正去注册钩子。这个问题如果不在 CI 配置里处理有可能会因为环境差异在pnpm install时报 hook 相关的错误白白耽误流水线时间。这里还要提醒一点不要在 CI 里用git commit --no-verify来绕过检查这是掩耳盗铃。CI 的检查应该是稳定的不依赖 git 客户端环境。把 lint、test、build 全部作为独立 stage 跑才符合企业级 CI 的可靠性要求。说到底这套模板的核心不是某个工具而是用自动化约束代替人工自觉的工程思维。我在几次迁移项目过程中最深的体会是配置本身并不难难的是让团队真正理解每一条配置在解决什么问题。只有大家都认同代码是写给后来人看的这句话工程化模板才能发挥它应有的价值。最后再分享一个小技巧模板建议直接放到公司内部的脚手架仓库里每次新项目 clone 后不要手动复制用degit或者pnpm create的方式自动拉取模板这样后续规范升级时所有存量项目可以一键同步而不是靠大家口口相传。这也是企业级 Monorepo 模板能长期落地、真正被团队用起来的关键一步。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。