先说说我上周的真实经历。团队从三个独立仓库切到 Monorepo 架构,目录、依赖、版本都理得挺顺,结果合并后第一次联调就翻了车:有人提交的一行代码把公共组件的导出路径写错,直接让两个应用编译失败,而这行错误直到 CI 跑完所有流水线才暴露出来,白白花掉所有人十五分钟。回头看,问题就出在“提交前没有任何检查”上。
这次要分享的,就是一套我从零搭出来的企业级 Monorepo 工程化模板。核心不复杂,就是 pnpm workspace 管理依赖和任务,TypeScript 统一类型体系,ESLint + Prettier 管代码风格,再重点把 Husky 9 和 lint-staged 集成到 Git 钩子里,让每次 git commit 之前自动完成“只针对本次改动文件”的检查。顺手还会把 commitlint 一起配上。适合正在搭建前端基础设施的团队,也适合一个人管理多仓库、想升级到 Monorepo 架构的开发者。
1. 动手之前:这套 Monorepo 模板到底要解决什么问题
1.1 多仓库时代的四个典型痛点
我见过太多团队一开始用多仓库,每个业务组件一个 repo,公共库再一个 repo,看起来井井有条,实际用起来处处受气。第一个痛点是依赖版本漂移。A 项目用react@18.2,B 项目用react@18.3,公共组件仓库还在用vue2,升级一个基础库要在五六个仓库里分别发版、分别改依赖,全凭微信群吼。
第二个痛点是共享代码靠复制粘贴。utils 函数、类型定义、基础组件,几乎在每一个业务仓库里都躺着一份副本。某天线上出了个 bug,你发现它存在于三份拷贝里,于是打开三个仓库逐个改,改到第二个的时候,第三个仓库的另一处引用又把问题带了出来。
第三个痛点是代码规范无法强制。有的项目开了 ESLint,有的项目没开;有的用 double quote,有的用 single quote;commit message 更是千奇百怪,“fix bug”“update”“改了一下”都是家常便饭。规范文档写得再细,没有工具兜底就等于没有规范。
第四个痛点是新同学上手成本极高。找公共组件不知道去哪个 repo 找,提 PR 不知道该提到哪里,本地同时维护一大串仓库,npm install都够喝一壶。这些痛点在项目超过 3 个、协作人数超过 5 人的时候会集中爆发。Monorepo 架构正是为了解决这些问题而存在的——把所有相关代码放进一个仓库,用 workspace 机制统一管理,让“改一处、处处生效”成为可能。
1.2 本模板的技术选型与理由
企业级 Monorepo 模板的选型不能拍脑袋,得看团队规模和实际诉求。包管理器我选了pnpm,原因是它的 workspace 支持非常干净,通过硬链接和内容寻址存储,能避免不同项目重复下载同一个依赖,而且workspace:*协议让本地包之间的引用关系一目了然。
任务编排器我留了Turborepo这个可选方案。它擅长做增量任务调度和构建缓存,适合模板后期扩展。如果你只维护两三个包,用 pnpm 自己的--filter就够用,先不引入 turbo 也完全没问题。
代码检查层是 ESLint 9 + Prettier。ESLint 9 全面转向 flat config,配置写起来比 eslintrc 清晰,也没有--ext这类历史包袱。Prettier 负责格式化,ESLint 负责逻辑规则,两者配合但不重叠。
提交防线就是题目里的主角:Husky 9 + lint-staged。Husky 负责把 Git hooks 签好,lint-staged 负责精确定位到暂存区里的文件,只对本次改动的文件做检查,而不是每次提交都全量 lint 几千个文件。最后加上 commitlint,把提交信息也纳入自动校验。
1.3 最终目录结构预览
整个模板的目录结构建议这样设计:
monorepo-template/ ├─ apps/ │ └─ web/ # 可部署应用 ├─ packages/ │ ├─ ui/ # 共享组件库 │ ├─ utils/ # 工具函数库 │ └─ config-eslint/ # ESLint 共享配置包 ├─ .husky/ │ ├─ pre-commit │ └─ commit-msg ├─ commitlint.config.ts ├─ eslint.config.ts ├─ pnpm-workspace.yaml ├─ turbo.json └─ package.jsonapps放最终可以被构建和部署的应用,packages放库和内部使用的配置包。这样在构建发布、代码审查时都有清晰的边界,后面加新项目也只需要在对应目录下新增一个子文件夹。
2. 初始化 workspace 与统一基础配置
2.1 用 pnpm workspace 初始化仓库骨架
第一步不需要任何复杂脚手架,直接手动建目录反而更清楚。先创建根目录并初始化package.json:
mkdir monorepo-template && cd monorepo-template pnpm init根package.json需要做两处关键修改。一是加"private": true,防止根包被意外发布到 npm;二是加packageManager字段,锁定 pnpm 的版本,避免团队里有人用 npm、有人用 yarn 导致 lockfile 混乱:
{ "name": "monorepo-template", "private": true, "version": "0.0.0", "packageManager": "pnpm@9.12.0", "scripts": {} }接着创建pnpm-workspace.yaml,告诉 pnpm 哪些目录属于 workspace 的子包:
packages: - "apps/*" - "packages/*"这里我把apps/*和packages/*分成两组,是刻意的。应用和库的生命周期完全不同:应用要频繁构建、部署,库则要关注版本发布和 API 稳定性。分开放置,后面配置Turborepo的dependsOn和发布脚本时都能省很多事。
2.2 根目录基础配置文件一次配齐
接下来把根目录的几个“隐形基础设施”铺好。
.gitignore的内容要照顾到 pnpm、Node、构建产物的常见目录:
node_modules/ dist/ .turbo/ *.tsbuildinfo .husky/_.npmrc里建议显式开启 workspace 协议保存,并让 pnpm 在安装时不要做太多“惊喜行为”:
save-workspace-protocol=true strict-peer-dependencies=false.editorconfig解决团队编辑器缩进和行尾统一的问题,这个文件小,但价值极高:
root = true [*] charset = utf-8 end_of_line = lf indent_style = space indent_size = 2 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false.prettierrc作为统一的格式化基准,我习惯用偏紧凑的风格:
{ "semi": true, "singleQuote": true, "printWidth": 100, "trailingComma": "all" }为什么这些配置放在根目录而不是每个子包一份?原因是“默认统一、例外覆盖”。绝大多数包直接用根配置即可,某天某个包有特殊风格,在它自己的目录放一个局部配置覆盖就好,这也是 Monorepo 基础设施的核心思路。
2.3 统一 TypeScript 与构建工具链
TypeScript 在 Monorepo 里最怕每个包各自为政,一个包用module: CommonJS,另一个用module: ESNext,互相引用时类型和产物全乱套。我在根目录放一个tsconfig.base.json作为公共底子:
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "declaration": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true } }moduleResolution: Bundler是 TypeScript 5 之后很推荐的选项,因为它能正确识别package.json里的exports字段,对打包器场景最友好。skipLibCheck: true可以显著提升大型 Monorepo 的类型检查速度,代价是跳过对node_modules类型声明文件的检查,实际项目中这个权衡非常划算。
每个子包的tsconfig.json只管自己的编译入口和输出目录,其余直接extends基础配置。构建工具方面,库类包推荐tsup,一条命令同时生成 ESM/CJS 和.d.ts声明文件;应用类包直接用Vite,这是目前体验最顺的前端构建方案。
2.4 用 workspace:* 协议管理包间依赖
在packages/ui和packages/utils里分别执行pnpm init,然后让ui依赖utils:
pnpm --filter @monorepo/ui add @monorepo/utils --workspace执行后packages/ui/package.json里会出现:
{ "dependencies": { "@monorepo/utils": "workspace:*" } }workspace:*的意思是“始终链接本仓库内对应包的最新版本”,发布时 pnpm 会自动把它替换成真实版本号。这个机制保证了本地开发时改utils立刻生效,发布后各包又能拿到语义化版本依赖,两边都不耽误。
3. 用 ESLint 9 + Prettier 统一代码规范
3.1 flat config 与共享配置包
ESLint 9 的 flat config 相比旧版 eslintrc 最大的变化是“一切皆数组”。你可以用数组里一个个配置对象描述规则、插件、文件匹配范围,更重要的是,它天然支持把一部分配置打包成 npm 包,再通过数组展开的方式复用,这正好契合 Monorepo 场景。
根目录的eslint.config.ts示例:
import js from '@eslint/js'; import tseslint from 'typescript-eslint'; import prettier from 'eslint-config-prettier'; export default tseslint.config( { ignores: ['**/dist/**', '**/node_modules/**', '**/.turbo/**'] }, js.configs.recommended, ...tseslint.configs.recommended, prettier, { rules: { '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], }, }, );这里我用了typescript-eslint包的tseslint.config方法,它会帮助合并类型信息,写起来也短。最后一项prettier是eslint-config-prettier,它的作用不是帮你格式化,而是把所有和样式相关的 ESLint 规则全部关掉,把格式问题完全交给 Prettier,避免两套工具打架。
3.2 让 Prettier 和 ESLint 的职责边界清晰
很多新手会纠结“到底用 Prettier 还是 ESLint”,其实答案很简单:ESLint 抓逻辑错误和代码模式问题,Prettier 管排版。比如你写了const a = 1;还是const a=1;,这种问题 Prettier 管;而“变量声明了但没用到”,这是 ESLint 的领域。
在 Monorepo 里,这种边界尤其重要。如果你让 ESLint 去管缩进和引号,那么每当有人想调整代码风格,就得同时折腾 ESLint 配置和 Prettier 配置,大概率会碰到规则互斥。我的做法非常干脆:ESLint 只开逻辑规则,Prettier 负责所有格式规则,两者通过eslint-config-prettier解耦。这样 lint-staged 里同时跑它们时,互相不会“打架”。
3.3 安装依赖并验证检查链
在根目录安装这组开发依赖:
pnpm add -D @eslint/js typescript-eslint eslint prettier eslint-config-prettier然后在根package.json里加上统一的检查脚本:
{ "scripts": { "lint": "eslint \"{apps,packages}/**/*.{ts,tsx,js,jsx}\"", "format": "prettier --write .", "typecheck": "tsc --noEmit" } }在packages/utils里放一个测试文件,随便写一个类型导入错误,比如没有用import type导入一个仅用于类型的接口。运行pnpm lint,如果报出@typescript-eslint/consistent-type-imports的错误,说明这条检查链已经通了。
企业级模板我建议把 ESLint 共享配置做成独立包packages/config-eslint,导出数组配置。这样不同子包可以按需扩展,比如 Node 端服务忽略浏览器全局变量,React 应用追加 hooks 规则,API 更加灵活。
4. 重头戏:Husky 9 + lint-staged 提交防线
4.1 Husky 9 和旧版本到底差在哪
Husky 9 是 2024 年发布的大版本,它解决了以往版本里最让人头疼的两个问题:安装过程“暗箱操作”太多、钩子脚本调试困难。
如果你用过 Husky 4,应该有印象,它会在package.json里写一个很长的husky.hooks配置段,安装依赖时自动往.git/hooks里塞脚本。这个设计在当时很惊艳,但也导致一个问题:仓库 clone 下来后,如果同事没跑npm install,钩子就没有;如果 CI 上npm install被跳过,钩子也不会出现在 CI 环境里。
Husky 6 以后改用.husky目录存钩子,通过husky install命令把 Git 的core.hooksPath指向这个目录。Husky 9 在此基础上进一步简化:不再需要手动执行husky install,只要运行npx husky init,它会自动创建.husky目录、生成一个示例pre-commit钩子,并在package.json里写入"prepare": "husky"。以后任何人拉代码后执行pnpm install,prepare 脚本就会自动把core.hooksPath指向.husky,钩子天然生效。
这个改动解决了团队协作里一大类“我这边明明装了 Husky 但还是不生效”的问题。现在只需要做一个动作:安装后执行npx husky init,之后交给 prepare 脚本自动维护。
4.2 安装并初始化 Husky 9
实际操作很简单:
pnpm add -D husky npx husky initnpx husky init完成后,你会看到.husky/目录下多了个pre-commit文件,同时根package.json里多出了:
{ "scripts": { "prepare": "husky" } }可以用下面这个命令验证 hooksPath 是否被正确设置:
git config core.hooksPath # 输出:.husky看到.husky就说明当前仓库的 Git hooks 已经指向项目内的.husky目录了。这个目录里的每个文件就是一个钩子,pre-commit会在你执行git commit时被 Git 自动调用。
4.3 安装 lint-staged 并掌握它的执行逻辑
lint-staged 的作用是“只检查暂存区文件”。它的执行流程是:读取git diff --name-only --cached得到暂存文件列表,用 glob 规则分组,匹配到的文件会传给对应的命令执行,命令成功后再把这些文件重新git add回暂存区。
安装并配置:
pnpm add -D lint-staged在根package.json中加入:
{ "lint-staged": { "*.{js,jsx,ts,tsx}": [ "eslint --fix", "prettier --write" ], "*.{json,css,scss,md,yml,yaml}": [ "prettier --write" ] } }这里有一个非常关键的心得:eslint --fix和prettier --write是“修改文件”的命令,lint-staged 在命令成功后会自己处理重新暂存,所以你千万不要在命令里手动再加git add。我见过有同事在 lint-staged 的数组里写["git add", "eslint --fix"],结果出现文件被重复修改、提交内容丢失等诡异问题,最后 debug 了半天才发现是画蛇添足。
4.4 在 pre-commit 钩子里串起 lint-staged
接下来把.husky/pre-commit修改成我们自己的命令。Husky 9 的钩子文件本质上就是一个 Shell 脚本,不需要再 sourcehusky.sh之类的辅助脚本,非常干净:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" pnpm exec lint-staged上面的内容里我保留了husky.sh的引用,这是 Husky 8 时代的写法,在 Husky 9 中并不是必需的。如果你用的是 Husky 9 初始化的项目,pre-commit 文件直接写:
pnpm exec lint-staged保存后,必须给钩子文件加执行权限,否则在 macOS/Linux 下钩子会无声地跳过:
chmod +x .husky/pre-commitpnpm exec lint-staged比npx lint-staged更推荐,原因是它在 pnpm 管理的 node_modules 里解析命令,不会因为 npx 的网络探测行为产生延迟,在 CI 环境里也更稳定。
4.5 完整链路验证:改一行代码再提交
为了验证整条链路,我在packages/utils里故意留一个格式错误文件,然后执行:
git add packages/utils/src/index.ts git commit -m "test: 验证提交防线"此时 Git 会先触发pre-commit钩子,lint-staged 拿到暂存区中唯一新添加的文件,执行 ESLint 和 Prettier。由于文件存在格式问题,提交会被中断,终端明确告诉你哪个文件、哪一行出了什么规则问题。把文件改好后重新git add、git commit,这一次通过。
这条链路的意义不只是“自动化”,而是把所有人在提交时的隐性动作变成显性约束:你在 IDE 里没跑 lint?没关系,提交时 Git 会帮你卡住。这种“肌肉记忆式”的规范落地,才是企业级模板该有的效果。
5. 提交信息规范化:commitlint 与 Conventional Commits
5.1 为什么提交信息也需要规则
代码规范能靠 lint-staged 兜底,但 commit message 是另一个高频翻车点。很多团队的提交历史看起来像大型车祸现场:“update”“fix”“改”,翻三个月前的提交,谁也说不清那次改动到底做了什么。
Commit message 规范化之后,至少有四个收益:可以自动生成 CHANGELOG;可以通过 message 过滤特定类型提交,快速定位 feature 和 bugfix;可以和版本发布工具联动;新同学看 Git 历史时能快速理解整个项目的演进脉络。
目前前端圈最常见的规范是 Conventional Commits,提交格式为:
<type>[optional scope]: <description>例如feat(user): add avatar upload。type常见的有feat、fix、docs、style、refactor、test、chore等。
5.2 安装并配置 commitlint
commitlint 负责把上面的规范变成自动检查器。安装两个包:
pnpm add -D @commitlint/cli @commitlint/config-conventional在根目录创建commitlint.config.ts,内容极简:
export default { extends: ['@commitlint/config-conventional'], };这里有个小坑要提醒:如果根package.json设置了"type": "module",那么.ts配置文件没问题;如果项目还在 CommonJS 模式,建议改成commitlint.config.cjs,内容换成:
module.exports = { extends: ['@commitlint/config-conventional'], };否则 commitlint 解析配置时会因为模块系统不一致直接报错,这种错误在团队里通常会浪费你好几分钟。
5.3 挂上 commit-msg 钩子并测试
手动创建.husky/commit-msg文件,内容如下:
pnpm exec commitlint --edit "$1"给它加执行权限,然后故意写一条不规范的提交信息试试:
git add . git commit -m "随便改点东西"commitlint 会拒绝这次提交,并提示你至少需要type(scope): description这种格式。改成:
git commit -m "fix(utils): correct the export path"提交正常通过。到这里,整个模板的“提交前检查 + 提交信息校验”双闸门已经全部上线。
6. 实战中的坑与企业级扩展
6.1 第一次接入时最常见的 5 个问题
我把实际落地时高频遇到的坑整理成一张表,方便你排查:
| 问题现象 | 根因 | 解决办法 |
|---|---|---|
| 安装 Husky 后提交不触发钩子 | core.hooksPath未指向.husky | 重新执行npx husky init,并确认git config core.hooksPath输出.husky |
Windows 下 pre-commit 报$'\r': command not found | 钩子文件被保存为 CRLF 行尾 | 在.gitattributes中加入.husky/** eol=lf,重新 checkout |
| lint-staged 没有检查任何文件 | 文件没有被git add暂存,或 glob 匹配不到 | 用git diff --name-only --cached查看暂存文件,再检查 glob 是否覆盖扩展名 |
| 某些包引入了 React 但 root ESLint 没有对应的插件 | 根配置过于通用,没有按包分层 | 把 shared ESLint 配置拆成包,允许子包用数组 expand 追加规则 |
同事用git commit --no-verify绕过钩子 | 人肉纪律问题 | 约定 code review 时检查提交信息;必要时在 CI 里再跑一次 lint-staged 或 commitlint |
关于 Windows 的 CRLF,这里再多说两句。.husky/pre-commit本质是 Shell 脚本,Windows 上如果某次 pull 把它转换成了 CRLF,脚本执行时会因为\r被解析为命令的一部分而直接报错。解决思路是在仓库根部放一个.gitattributes:
* text=auto .husky/** eol=lf这样无论团队里谁在什么系统上操作,.husky目录下的文件始终保留 LF 行尾。
6.2 不要让钩子过度臃肿,把重量级检查留给 CI
我在搭模板时特别提醒自己:pre-commit 钩子只做“秒级检查”,单元测试、构建、全量类型检查这些重量级任务不要让本地开发人员全吃。
lint-staged 保证了我们只检查本次改动的文件,所以eslint --fix、prettier --write加起来通常在几百毫秒到一两秒之间,这个体验对开发者是友好的。但如果你把pnpm test放进 pre-commit,第一次跑也许还能忍,到后期测试多了,每次提交等两分钟,团队里一定会有人开始变着法绕钩子。
我的经验是:pre-commit 放 lint-staged 和 commitlint;类型检查、单元测试、构建放 CI 流水线。这样本地提交快,远程质量有兜底,两边各司其职。如果你觉得本地也想快速跑类型检查,可以用pnpm typecheck作为 pre-push 钩子,只在推送前执行一次,比塞进 commit 里合理得多。
6.3 后续可以继续扩展的方向
这套模板再往深处走,有几个方向值得你按团队情况叠加。
第一个是版本发布流程。Monorepo 做多个共享库时,推荐接入changesets,它会自动管理版本号、生成 CHANGELOG,并支持按包粒度发布。安装后运行npx changeset init得到.changeset/config.json,配合 CI 里的changeset version和changeset publish两步,就能把发布流程标准化。
第二个是应用层的命令编排。如果apps越来越多,构建顺序和缓存会变得棘手,此时再把 Turborepo 真正接入。根turbo.json示例:
{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "lint": {}, "dev": { "cache": false, "persistent": true } } }Turborepo 能根据依赖图自动安排构建顺序,并缓存每一层的结果。仓库大到一个命令要跑五分钟时,它的价值会非常明显。
第三个是模板分发。企业里往往有多个项目需要统一使用这套基础设施,可以把它打成模板仓库,用degit快速复制,或者在公司内部搭建自有的脚手架工具,直接通过命令行生成新项目。这样“工程化模板”才真正完成闭环。
最后再分享一个我自己的体会:Husky 9 和 lint-staged 是典型的“小工具、大收益”组合,它们不复杂,却能把代码规范和提交流程从口号变成肌肉记忆。但工具只是辅助,真正决定工程质量的还是团队对规范的共识。模板搭好之后,一定要找机会跟团队一起过一遍整个流程,让大家理解为什么提交信息要规范、为什么只检查改动的文件,而不是扔一个仓库链接让大家自己琢磨。工程化的本质,是让每个环节都有清晰的规则和即时反馈,而 Hooks 机制是其中性价比最高的一环。