从年初开始我所在的前端团队就一直被多仓库维护的问题困扰。某个通用组件库散落在三个不同项目的代码库里修一个 bug 要分别发版、分别通知、分别等对方升级光协调成本就占了大半时间。后来我们决定把核心项目统一收进一个 monorepo 里花了大概两周时间从零搭建过程中踩了不少坑最后沉淀出一套目前跑得比较稳的实践方案。这篇文章就把完整的搭建思路、工具选型逻辑、以及实际踩过的问题都整理出来给同样打算从 0 搭建 monorepo 的团队做个参考。1. monorepo 解决的真实痛点多仓库维护的隐性成本先聊清楚 monorepo 到底在解决什么问题。很多人以为 monorepo 就是把代码塞进同一个 Git 仓库这只是表面。真正驱动团队做迁移的往往是下面这几类隐性成本。1.1 代码复用与版本同步的尴尬多仓库模式下公共代码的复用通常有两种做法一种是把公共逻辑抽成独立包发到私有源上另一种是直接复制粘贴。前者的问题是发布链路太长改动一个函数要经历“改代码—提交—构建—发布—升级依赖—重新部署”一整条流程中间任何一步卡住都会阻塞业务后者的问题更隐蔽复制出来的代码会在多个仓库里各自演化三个月后你根本分不清哪个版本才是最新的修 bug 得把所有副本都翻出来改一遍。我在团队里做过一次统计仅一个权限校验工具函数就存在 4 个不同版本其中两个版本的行为已经和最初设计完全不一致了。这种情况在 monorepo 下很难出现因为所有代码都在同一个仓库里改动即时可见发布时采用统一的版本策略根本不需要“跨仓库同步”这套动作。1.2 原子提交带来的安全感和效率提升跨仓库改动是一件让人精神高度紧张的事情。假设一个接口类型定义在 A 仓库接口实现逻辑在 B 仓库前端页面在 C 仓库一次需求改动就可能要开三个 MR分别合入再分别发布。如果三个仓库的发布窗口不同步中间就会出现一段“类型已改但实现未改”的脆弱期线上偶发问题排查起来特别困难。monorepo 的原子提交彻底解决了这个问题。一次提交可以同时覆盖类型定义、组件实现和页面调用代码评审时能在一个 MR 里看到完整上下文回滚时也能整体回滚。就这一点我认为就足以成为很多团队迁移的首要理由。1.3 依赖安装与构建效率的对比多仓库模式下容易出现依赖地狱——项目 A 里升级了某个底层库项目 B 没升项目 C 升了但用了不同的版本抽出来的公共包为了兼容不同版本往往被迫写成最外层提供依赖注入接口代码可读性急剧下降。monorepo 配合现代包管理器做依赖提升后公共依赖通常只安装一份磁盘占用和 CI 安装时间都能明显下降。我们迁移后CI 的依赖安装时间从 4 分钟降到了 1 分 20 秒左右这个提升在团队里感知非常明显。2. 工具选型为什么我选 pnpm workspace 而不是 Lerna 或 Nx搭建 monorepo 的第一步是选工具。目前主流的方案有 pnpm workspace、Lerna、Rush、Nx我最终选了 pnpm workspace原因是它足够轻量又覆盖了我们 95% 的需求。下面把选型逻辑说清楚。2.1 候选方案的快速对比先放一张我选型时做的对比表方便大家直观理解差异维度pnpm workspaceLernaNxRush依赖管理使用 pnpm 自带的严格依赖隔离通常配合 npm/yarn/pnpm 使用默认使用 npm/yarn/pnpm依赖管理依赖底层包管理器自带集中式依赖管理规则严格任务编排需要配合 --filter 手动串行/并行提供 run 和 run --parallel内置强大的任务图编排自动缓存内置任务执行器支持增量构建学习成本低只要会 pnpm 就能上手低命令直观中高概念较多配置多高需要理解它的仓库哲学适合场景中小团队、组件库、轻量工程化传统多包发布维护历史包袱的老项目大型全栈仓库需要高效 CI 缓存大团队、强治理诉求的超大仓库Lerna 曾经是 monorepo 的标配但它本身不解决依赖提升问题需要配合其他包管理器使用而且它的发布流程对现在我们这种“不需要频繁独立发版”的团队来说太重了。Nx 很强大任务缓存、依赖图可视化都是亮点但引入 Nx 等于需要团队额外学习一整套插件体系前期改造成本偏高。Rush 适合那种几百个包、上千人的大型仓库规则严格迁移成本极高我们不打算一开始就背上这么重的约束。相比之下pnpm workspace 足够简单依赖管理本身就是它的核心优势直接满足了我们 80% 的需求额外新增的 20%。可以靠少量自定义脚本补齐。2.2 pnpm workspace 的核心优势pnpm 与其他包管理器最大的不同是它的虚拟存储目录设计。它不会把每个包所需要的依赖都平铺到各自的 node_modules 里而是在磁盘上维护一个全局的内容寻址存储空间项目里的 node_modules 只是这个存储空间的链接。这意味着同样一份依赖无论在仓库里被几个子包使用只会在磁盘上保留一份。安装速度大幅度提升同时因为依赖提升有限子包无法访问它未声明过的依赖——也就是所谓的严格依赖隔离这避免了很多“本地能跑换台机器就报错”的灵异问题。对于我们这个以 TypeScript 为主的 monorepo 来说pnpm workspace 的约束力和灵活度正好处在合适的平衡点上它不会强制你采用某种构建系统也不会引入额外的 CLI 概念只是一个非常干净的 workspace 机制剩下的工程化能力我可以自己拎着工具组合。2.3 为什么最终没有选 Lerna坦白说如果团队里已经有大量 npm 生态的历史包袱Lerna 还是值得考虑的。它提供了成熟的批量发布、版本管理、changelog 生成能力对于需要在 monorepo 里维护多个独立对外发布包的团队非常友好。但我们团队的情况不一样。我们内部很少需要独立对外发布包大部分场景是把所有代码作为一个整体构建、部署。Lerna 的核心能力对我们属于“需要但用不上”而它的集成复杂度却很高。选型时一定要想清楚团队的实际使用场景而不是看着功能列表逐项打勾。3. 从空目录到第一个共享包完整搭建步骤确定工具之后就可以开始真刀真枪地搭了。下面按照我实际操作的顺序把每一步都列出来包括初始化、目录设计、TypeScript 配置、workspace 协议、构建编排这些核心操作。3.1 初始化项目与目录结构设计先创建一个空目录并初始化 Git 和 package.jsonmkdir frontend-monorepo cd frontend-monorepo git init pnpm init接着创建 pnpm-workspace.yaml 文件告诉 pnpm 仓库里有哪几个子包packages: - packages/*这是 pnpm workspace 的核心文件。这里我们约定所有子包都放在 packages 目录下packages 里的每一个子目录就是一个独立的包。目录结构设计为frontend-monorepo/ ├── packages/ │ ├── core/ # 核心工具函数与类型定义 │ ├── components/ # 公共 UI 组件 │ ├── app-admin/ # 管理后台应用 │ └── app-portal/ # 门户站点应用 ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json └── .gitignore根目录的 package.json 我们设置为私有包避免被意外发布到源上{ name: frontend-monorepo, private: true, scripts: { build: pnpm -r build, dev: pnpm --filter app-admin dev } }pnpm -r build会递归执行所有子包的 build 脚本这是最基础的编排方式后面章节会讲更精细的编排方案。3.2 TypeScript 项目引用与基准配置monorepo 里最容易出现的问题就是 TypeScript 类型引用混乱。因为不同子包都在独立编译如果 A 包引用了 B 包B 包还没有构建产物TypeScript 就会报错找不到模块。我的做法是使用 TypeScript 的 project references 功能配合每个包自己的 tsconfig.json 做增量编译。先放一个根目录的 tsconfig.base.json{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: Node, strict: true, declaration: true, declarationMap: true, composite: true, noEmit: false, outDir: dist, baseUrl: . } }composite: true是一个关键配置它开启了 TypeScript 的项目引用模式——tsc 会把当前项目视为可被其他项目引用的“工程”并生成.tsbuildinfo文件用来跟踪上次编译的文件状态这样后续增量编译时效率会高很多。每个子包的 tsconfig.json 都继承根配置并声明依赖的项目引用{ extends: ../../tsconfig.base.json, compilerOptions: { outDir: ./dist, rootDir: ./src }, references: [ { path: ../core } ] }核心包 core 不声明任何 references它是被依赖的最底层。components 包引用 coreapp-admin 和 app-portal 都引用 core 和 components。这样 TypeScript 就能根据项目引用的顺序自动完成编译排序不用人为控制构建先后顺序。3.3 创建第一个核心包并跑通依赖在 packages 下创建 core 包mkdir -p packages/core/srcpackage.json{ name: your-scope/core, version: 0.0.0, main: ./dist/index.js, module: ./dist/index.mjs, types: ./dist/index.d.ts, exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.mjs, require: ./dist/index.js } }, scripts: { build: tsc -b }, devDependencies: { typescript: ^5.4.0 } }注意这里的main、module、types都指向构建产物目录而不是指向 src。很多初学者习惯让模块解析直接指到src/index.ts这样开发期没问题但一旦上线部署后端跑的是打包后的代码源码引用路径就会失效。把入口指向 dist 是更稳妥的规范做法。源文件 src/index.tsexport const add (a: number, b: number): number a b; export interface UserProfile { id: string; name: string; email: string; }现在在 core 目录下执行pnpm install再执行pnpm buildcore 包就会把 src 编译到 dist 目录。此时 core 包已经可以被其他包引用了。3.4 让另一个包依赖 core 包以 components 包为例它要引入 core只需要在 components 的 package.json 依赖字段写上{ dependencies: { your-scope/core: workspace:* } }workspace:*是 pnpm workspace 的关键语法它表示安装时直接从当前仓库链接这个包而非从源上下载。这样做有两个好处一是保证 components 包引用的核心代码一定是当前仓库的本地源码二是 pnpm 会自动建立软链接修改 core 的代码后components 这边不需要重新安装依赖就能感知到变化。完成依赖声明后在仓库根目录执行pnpm install这个命令会在根目录统一安装所有 workspace 包的依赖并建立包之间的链接关系。核心这里强烈建议在根目录统一执行pnpm install不要每个子包单独执行否则很容易出现依赖树不一致的问题。3.5 验证依赖链路是否通畅为了验证整体链路我在 packages/components/src/index.ts 里写了这样一段代码import { UserProfile } from your-scope/core; export interface AdminUser extends UserProfile { role: admin | editor; } export const formatUserName (user: UserProfile): string { return user.name.toUpperCase(); };然后进入 components 目录执行tsc -bTypeScript 会先检测到当前项目引用了 core自动编译 core再编译 components——因为它是一个项目引用。如果类型和构建流程都正常说明从核心包到依赖包的链路已经打通了。这一步是整个 monorepo 的基石只要跑通这一步后面无论加多少包都是同样的套路放一个新的包目录声明 workspace 依赖配置好自己的 tsconfig然后构建即可。4. 踩坑实录依赖链接、构建缓存与版本发布的连环坑理想很丰满现实很骨感。上面这套流程我跑通之后觉得也不过如此但真正把多个包接入业务应用时一连串问题纷至沓来。下面挑几个坑得最深的按排查思路完整还原方便你复现我的调试过程。4.1 遇到报错“找不到模块”时的完整排查链路场景我在一个业务应用包中引用了 components 包的某个组件运行时浏览器 console 报错Module not found: Cant resolve your-scope/components/Sidebar第一反应是包构建没跑。于是到 packages/components 目录下执行构建结果构建成功dist 产物完整。那为什么业务应用还是找不到呢排查链路如下检查 components 的 package.json确认 exports 字段是否有导出 Sidebar 子路径。如果有exports: { .: ./dist/index.js }但没有./Sidebar: ./dist/Sidebar.js那从子路径导入自然失败。打开业务应用目录下的node_modules/your-scope/components看看这个软链接指向哪里。执行ls -l后发现它确实指向了 packages/components 目录说明 workspace 链接是正常的。查看具体的包入口文件确认 dist/index.d.ts 里是否正确导出了 Sidebar 组件类型。发现 dist/index.d.ts 引入的是./src/index的相对路径而不是./dist/index。问题根源就在这里——components 包的 tsconfig 里declarationMap生成后.d.ts文件里保留了源码相对路径的引用关系而业务应用的 bundler 在解析这些声明文件时无法正确转换成 dist 路径。我的修复方式是统一调整子包构建配置让每个包都采用tsc -b模式并且在根目录 tsconfig.base.json 里设置{ compilerOptions: { rootDir: ., preserveSymlinks: false } }再加上确保子包 exports 字段配置到每一个要暴露的子路径问题才彻底消失。4.2 pnpm 的幽灵依赖与 workspace 协议版本号问题pnpm 严格依赖隔离的代价就是你不能再偷偷使用一个没有在 package.json 里声明过的依赖。我们有一个业务包引用了 lodash但它的 package.json 里漏了 lodash 依赖声明——因为之前用 npm/yarn 时依赖提升到根目录无论从哪里都能直接 require换到 pnpm 之后直接报 module not found。这个坑排查起来耗时较长。当时我们先确认 node_modules 里确实有 lodash安装在根目录的虚拟存储里但业务包的 node_modules 没有对应入口pnpm 是不会允许子包访问它没有声明的依赖的。最终修复方式是老老实实把 lodash 加进业务包的 dependencies 里再运行pnpm install。另一个让我头疼的问题是 workspace 协议在打包后的版本号显示为workspace:*。开发时一切正常但当我把某个包发布到私有源别的项目安装它时这个依赖的版本声明就变成了不正确的值。后来查文档才知道pnpm 在发布时不会自动重写 workspace 协议需要你在构建脚本里做一次版本替换。最简单的方式是发布时用pnpm publish --recursive它会自动把 workspace 协议替换为实际版本号。如果你用别的方式手动打包就需要写一个简单的脚本在 publish 前把workspace:*替换成包的真实版本号。4.3 循环依赖会导致构建顺序彻底卡死随着包数量增多包之间的依赖关系变得越来越复杂。某次我让 core 包引用了一个放在 utils 包里的函数而 utils 包又反向引用了 core 的类型。两个包都设置了 project references 指向对方结果一跑tsc -b它就开始告诉你循环依赖构建直接中断。这个错误通俗点说就是 A 想先编译但编译 A 前必须编译 B而编译 B 前又必须先编译 A两个工程互相等着对方谁也不让谁。TypeScript 的 project references 模式比普通构建工具更严格地禁止这种情况。解决思路有两个方向如果只是类型上的循环可以把公共类型单独抽到最底层的 core 包让其他包只依赖 corecore 不依赖任何业务包。这是最治本的方式。如果确实无法避免运行时循环依赖也可以把其中一方改成动态 import这样构建时就不会要求先编译完整文件。我们最终的做法是建立了明确的依赖分层规则core 是最底层的公共层不依赖任何业务包utils 依赖 corecomponents 依赖 utils 和 core业务应用只能依赖 components、utils 和 core不允许业务之间互相引用。有了这几条硬约束之后再也没有出现过循环依赖的问题。4.4 构建缓存失效导致的“旧代码上线”事故TypeScript 的 project references 模式依赖.tsbuildinfo文件做增量编译这个文件默认生成在每个子包根目录。它记录的是每个文件的时间戳和内容哈希理论上只要源码没变增量编译就不会重新生成。这个机制在正常开发流程下是没问题的但在 CI 环境里容易踩坑。某次 CI 的缓存路径配置错了导致.tsbuildinfo要么没被清理、要么被错误复用最终把旧代码打进了产物。线上出现的问题和我本地已经完全修复的代码行为不一样排查了整整半天才定位是缓存所致。我的经验是在 CI 中专门为*.tsbuildinfo文件配置独立的缓存目录并在每次添加新包时清理旧缓存。一个简单的做法是在构建脚本中先执行find . -name *.tsbuildinfo -delete虽然会让增量编译的优化效果打折扣但能保证每次构建都是基于最新源码。在业务稳定后可以对 CI 的缓存策略做更细的配置但初期宁可牺牲一点构建速度也要保证构建结果的确定性。5. 规模化后的进阶实践过滤执行、任务编排与团队约束当 monorepo 里的包数量从 3 个增长到 10 个以上手动在根目录执行pnpm -r build就会变得很笨重。你不可能每次都把所有包全量构建一遍因此需要学会用 pnpm 的过滤器和任务编排能力来提升效率。5.1 用 --filter 精准定位包范围pnpm 提供了非常灵活的过滤语法常用组合方式如下命令作用pnpm --filter your-scope/core build只构建 core 包pnpm --filter your-scope/components build只构建 components 包pnpm --filter ...your-scope/components build构建 components 以及它依赖的所有包pnpm --filter your-scope/components... build构建 components 以及依赖它的所有包pnpm --filter ./packages/** test按路径匹配运行所有 packages 下包的测试... 这个语法值得专门解释。--filter ...A中的...表示“A 依赖的包”也就是先编译底层依赖再编译 A--filter A...则表示“依赖 A 的包”比如你改了 components希望把引用 components 的所有业务应用都跑一遍测试这个命令就非常顺手。在 CI 中我们可以按照 diff 出的文件来精准决定要跑哪一组构建changed$(git diff --name-only origin/main...HEAD -- packages | cut -d/ -f1-2 | sort -u) for pkg in $changed; do pnpm --filter $pkg... run build done这个脚本的用意是只针对变更的包及其依赖链执行构建避免全量构建带来的时间和资源浪费。实际使用下来CI 构建时长平均缩短了差不多一半。5.2 根目录脚本的统一管理与自定义包装命令当包数量多了以后子包里的 scripts 往往会出现大量重复比如每个包都有 build、test、lint 这三个命令。我的做法是在根目录定义统一的集合命令同时暴露出精简的日常操作入口{ scripts: { build: pnpm -r --stream build, test: pnpm -r --stream test, lint: pnpm -r --stream lint, gen: node scripts/gen-component.mjs } }--stream参数会在执行时保留每个子包的输出流前缀方便观察是哪个包在输出日志排查问题的时候非常有用。如果没有--stream日志会混在一起很难定位。自定义脚本则是围绕团队实际工作流的效率工具。比如我们用了一个scripts/gen-component.mjs输入一个组件名就自动创建组件目录、生成模板文件并注册路由杜绝了新建组件时手动改名不一致的尴尬。这类自动化工具体现的是 monorepo 的统一约束能力——所有包都按同一种规范创建、命名、暴露才敢放心地让包数量继续增长。5.3 给团队的目录规范与依赖分层约定工具能提供约束但团队能走多远靠的是清晰的约定。我们在迁移后的近半年里形成了一套非常简单的规则所有包放在packages/*下包名使用your-scope/*统一 scope。core 包是唯一不允许依赖其他内部包的底层包它只放纯函数、纯类型、基础常量。utils 包可以依赖 core专门放业务通用的算法、格式化、请求封装等逻辑。components 包只能依赖 utils 和 core不许依赖任何业务应用包。业务应用包之间严禁互相依赖如果有共享诉求下沉到 components 或 utils 中实现。这套分层约束没有什么高深理论就靠 Code Review 强执行。有了它之后新增依赖的循坏问题几乎绝迹构建顺序也不再需要人为记忆。如果你在搭建 monorepo 的初期就把这些规则和团队达成一致后续的治理成本会低很多。5.4 持续集成里的增量构建与发布策略最后说说 CI 环节。我们的 CI 流水线大致分为检查、构建、部署三个关卡检查阶段运行 lint 和单元测试使用pnpm --filter [changed packages]...精准定位不做全量跑。构建阶段先用 git diff 拿到变更范围再针对变更包及其上层依赖做构建产物统一输出到远程缓存。部署阶段只有业务应用包被变更时才触发业务应用的部署core/utils/components 的变更会被构建后作为本地 workspace 包被上层应用引入通过上层应用的发布动作完成线上更新。这种策略让改动一个工具函数时不再需要去改业务应用的任何代码但部署业务应用时又能包含最新代码交付效率高很多。如果你团队的发布节奏比较传统还是以每个包独立发布到源为主那就要在发布脚本里对齐 workspace 协议替换那一环避免发布出去的包依赖信息损坏。跑通这套流程后我最大的体会是monorepo 不是银弹它解决了很多工程协作痛点却也把一部分“跨仓库协作复杂度”转移成了“单仓库内的结构复杂度”。工具选型只是第一步真正决定成败的是团队是否愿意遵守依赖分层约定。只要大家在进入仓库第一天就理解 core 包不许引用业务包、业务应用之间不许互引这两条铁律接下来的构建编排和 CI 优化都会顺畅很多。如果你正在犹豫要不要上 monorepo我建议先按你团队实际的发布节奏和包数量评估一下如果还没有超过 3 个独立管理的包不要急着迁移反之尽早统一管理后续的工程化收益会越来越明显。