ARTICLE DETAIL

资讯详情

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

企业级Monorepo工程化:用Husky 9 + lint-staged构建提交防线

企业级Monorepo工程化:用Husky 9 + lint-staged构建提交防线 先说说我上周的真实经历。团队从三个独立仓库切到 Monorepo 架构目录、依赖、版本都理得挺顺结果合并后第一次联调就翻了车有人提交的一行代码把公共组件的导出路径写错直接让两个应用编译失败而这行错误直到 CI 跑完所有流水线才暴露出来白白花掉所有人十五分钟。回头看问题就出在“提交前没有任何检查”上。这次要分享的就是一套我从零搭出来的企业级 Monorepo 工程化模板。核心不复杂就是 pnpm workspace 管理依赖和任务TypeScript 统一类型体系ESLint Prettier 管代码风格再重点把 Husky 9 和 lint-staged 集成到 Git 钩子里让每次 git commit 之前自动完成“只针对本次改动文件”的检查。顺手还会把 commitlint 一起配上。适合正在搭建前端基础设施的团队也适合一个人管理多仓库、想升级到 Monorepo 架构的开发者。1. 动手之前这套 Monorepo 模板到底要解决什么问题1.1 多仓库时代的四个典型痛点我见过太多团队一开始用多仓库每个业务组件一个 repo公共库再一个 repo看起来井井有条实际用起来处处受气。第一个痛点是依赖版本漂移。A 项目用react18.2B 项目用react18.3公共组件仓库还在用vue2升级一个基础库要在五六个仓库里分别发版、分别改依赖全凭微信群吼。第二个痛点是共享代码靠复制粘贴。utils 函数、类型定义、基础组件几乎在每一个业务仓库里都躺着一份副本。某天线上出了个 bug你发现它存在于三份拷贝里于是打开三个仓库逐个改改到第二个的时候第三个仓库的另一处引用又把问题带了出来。第三个痛点是代码规范无法强制。有的项目开了 ESLint有的项目没开有的用 double quote有的用 single quotecommit 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.jsonmkdir 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: pnpm9.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-protocoltrue strict-peer-dependenciesfalse.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依赖utilspnpm --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 a1;这种问题 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 installprepare 脚本就会自动把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 Commits5.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 安装并配置 commitlintcommitlint 负责把上面的规范变成自动检查器。安装两个包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输出.huskyWindows 下 pre-commit 报$\r: command not found钩子文件被保存为 CRLF 行尾在.gitattributes中加入.husky/** eollf重新 checkoutlint-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* textauto .husky/** eollf这样无论团队里谁在什么系统上操作.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 机制是其中性价比最高的一环。
返回列表