企业级Monorepo工程化模板:Husky 9与lint-staged集成实践
2026/9/19 21:18:07 网站建设 项目流程

先说个我上个月真实踩过的坑。团队里接了个老项目,典型的多仓库结构——一个公共组件库、两个业务前端、一个 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.yaml

packages: - packages/* - apps/* - docs

这个文件告诉 pnpm 哪些目录属于 workspace。我习惯分成packages(给其他包用的公共库)和apps(可部署的业务应用),docs单独放文档站点。从语义上把"库"和"应用"区分开,后续配置 lint、构建、发布规则时可以针对性设置。

然后初始化根package.json,关键字段如下:

{ "name": "enterprise-platform", "private": true, "packageManager": "pnpm@9.15.0", "engines": { "node": ">=20.0.0" }, "scripts": { "build": "pnpm -r build", "dev": "pnpm -r --parallel dev", "lint": "eslint . --max-warnings=0", "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-warnings=0是我给全量 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 init

pnpm dlx husky init

执行完这一步,Husky 9 会做三件事:

  1. 创建.husky/目录。
  2. 生成一个示例文件.husky/pre-commit,默认内容是npm test
  3. 自动在根package.json写入"prepare": "husky"

.husky/目录就是所有 Git Hooks 的存放位置,每个文件名对应一个 hook 名,内容是一段 shell 脚本。默认的pre-commit文件长这样:

npm test

注意,它没有#!/usr/bin/env sh之类的 shebang,Husky 9 在初始化时会自动处理可执行权限和运行时环境,不需要我们手动加。我见过很多人会画蛇添足加一堆source命令,反而容易出问题。

3.2 从 v8 迁移到 v9:最关键的三个变化

如果你的项目还在用 Husky 8,升级到 9 之前务必了解三个关键变化:

第一,husky install命令被移除了。旧版本在prepare脚本里写的是husky install,v9 直接写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 的原理。它做的事情可以拆成四步:

  1. 读取 Git 暂存区,找到所有被 staged 的文件列表。
  2. 拿这份文件列表和你的 glob 配置做匹配,找出命中的文件。
  3. 对每个匹配规则,执行配置的那一串命令,把文件路径作为参数传进去。
  4. 命令执行成功后,把可能被自动修复过的文件重新加入暂存区。

所以 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 add,lint-staged 是看不到它的。所以一定要让团队养成git add后再 commit 的习惯。lint-staged 默认会处理 staged 文件,但如果你用git commit -a这种自动暂存方式,部分 IDE 的表现可能不同。最稳妥的做法:显眼位置提醒好团队,先 add 后 commit。

5. 提交信息与代码风格的完整闭环

5.1 commitlint 校验 commit-msg

pre-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-warnings=0", "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看输出是否为.husky
prepare 脚本报 "command not found: husky"node_modules 没装好或脚本写错检查pnpm install是否成功,确认 prepare 内容是husky而非husky install
lint-staged 跑起来但 ESLint 不检查glob 模式没匹配上文件检查 lint-staged 配置里的路径模式,packages/**/*.ts*.ts匹配范围完全不同
同一组文件只有 prettier 生效配置了重复 key 的 glob确认 ESLint 和 Prettier 命令放在同一个数组里
新 clone 下来钩子失效文件权限丢失执行chmod +x .husky/pre-commit .husky/commit-msg或重新pnpm install
Windows 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-commit(Git 2.36+ 支持的命令)手动测试钩子,看能不能复现问题。第五步,看.git/hooks目录有没有被其他工具干扰。

这里分享一个我实际遇到过的情况:同事用 SmartGit 提交代码,SmartGit 默认绕过core.hooksPath,Husky 钩子一次都没触发。这种 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 阶段意外报错:

HUSKY=0 pnpm install --frozen-lockfile pnpm lint pnpm build pnpm test

HUSKY=0是 Husky 9 官方支持的跳过环境变量,设置后 prepare 脚本不会真正去注册钩子。这个问题如果不在 CI 配置里处理,有可能会因为环境差异在pnpm install时报 hook 相关的错误,白白耽误流水线时间。

这里还要提醒一点:不要在 CI 里用git commit --no-verify来绕过检查,这是掩耳盗铃。CI 的检查应该是稳定的,不依赖 git 客户端环境。把 lint、test、build 全部作为独立 stage 跑,才符合企业级 CI 的可靠性要求。

说到底,这套模板的核心不是某个工具,而是"用自动化约束代替人工自觉"的工程思维。我在几次迁移项目过程中,最深的体会是:配置本身并不难,难的是让团队真正理解每一条配置在解决什么问题。只有大家都认同"代码是写给后来人看的"这句话,工程化模板才能发挥它应有的价值。

最后再分享一个小技巧:模板建议直接放到公司内部的脚手架仓库里,每次新项目 clone 后不要手动复制,用degit或者pnpm create的方式自动拉取模板,这样后续规范升级时,所有存量项目可以一键同步,而不是靠大家口口相传。这也是企业级 Monorepo 模板能长期落地、真正被团队用起来的关键一步。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询