工程化(二):为什么你的下一个项目应该使用Monorepo?(pnpm / Lerna实战)
工程化(二):为什么你的下一个项目应该使用Monorepo?(pnpm / Lerna实战)
引子:前端项目的“孤岛困境”
随着你的项目或团队不断成长,一个棘手的问题会逐渐浮现:代码该如何组织?
最传统、最直观的方式,是**多仓库(Polyrepo)**模式:一个项目,一个Git仓库。
- 你有一个
my-awesome-app
的前端应用仓库。 - 你有一个
my-shared-utils
的共享工具函数仓库。 - 你有一个
my-ui-components
的通用UI组件库仓库。
一开始,这看起来很美。每个项目职责单一,独立演进。但很快,你会陷入“孤岛困境”带来的痛苦之中:
-
依赖管理地狱:
my-awesome-app
依赖my-shared-utils
的1.0.0
版本。- 现在,你为了修复一个bug,在
my-shared-utils
里发布了1.0.1
版本。 - 你必须回到
my-awesome-app
仓库,更新package.json
,运行npm install
,提交、发布,才能用上这个修复。 - 如果
my-ui-components
也依赖了my-shared-utils
呢?你需要把这个更新流程在每一个依赖它的仓库里都重复一遍!这个过程极其繁琐、耗时且容易出错。
-
原子性变更的缺失:
- 假设一个重大的功能变更,需要同时修改后端API、前端应用和共享组件库。这需要你在三个不同的仓库里,创建三个独立的Pull Request。
- 这三个PR很难保证被同时合并。如果其中一个合并了,而另外两个没有,你的线上环境就可能处于一个不一致的、破碎的状态。
-
代码复用与重构的巨大阻力:
- 当你想把
my-awesome-app
中的一个通用函数,抽离到my-shared-utils
中时,这个看似简单的操作,需要跨越两个仓库,流程瞬间变得复杂。 - 大规模的重构(比如升级一个核心库的主版本)更是天方夜谭,因为它需要在所有相关的“孤岛”上同步进行。
- 当你想把
-
开发环境的不一致:
- 每个仓库都有自己的一套
eslint
配置、typescript
配置、构建脚本。保持它们之间的同步和一致,本身就是一项巨大的维护成本。
- 每个仓库都有自己的一套
如果你正在经历这些痛苦,那么,是时候了解一种更现代、更高效的代码组织范式了——Monorepo。
第一幕:Monorepo - 从“孤岛联邦”到“统一帝国”
Monorepo,即单体仓库(Monolithic Repository),其核心思想非常简单:
将多个逻辑上独立、但实际上互相依赖的项目,统一存储在同一个Git仓库中。
Google, Meta, Microsoft等许多大型科技公司,都在内部大规模地使用Monorepo来管理他们庞大而复杂的代码库。开源社区中,Babel, React, Vue, NestJS等知名项目,也无一例外地采用了Monorepo的组织方式。
一个典型的Monorepo文件结构可能长这样:
/my-monorepo
├── packages/
│ ├── app-a/
│ │ └── package.json
│ ├── app-b/
│ │ └── package.json
│ ├── shared-utils/
│ │ └── package.json
│ └── ui-components/
│ └── package.json
├── package.json // 根package.json
├── pnpm-workspace.yaml // Monorepo配置文件
└── tsconfig.json // 统一的TS配置
在这个结构中,packages
目录下的每一个子目录,都是一个独立的、拥有自己package.json
的本地包(Local Package)。
这看起来只是把多个项目文件夹放在了一起,但它在现代包管理工具(如pnpm, yarn, npm)的“workspace”特性的加持下,能爆发出惊人的威力,完美地解决了Polyrepo的四大痛点。
第二幕:pnpm Workspace - Monorepo的“魔力引擎”
虽然Lerna是Monorepo领域的老牌工具,但随着npm, yarn, pnpm等包管理器原生支持了workspace
(工作区)功能,现代Monorepo的最佳实践,已经转向了**“包管理器 + 专用工具”**的组合。
其中,pnpm因其高效的磁盘空间利用和卓越的性能,成为了搭建Monorepo的首选。
pnpm workspace
的核心魔力在于:它能自动地在本地包之间建立符号链接(Symbolic Link)。
让我们回到那个依赖管理的噩梦。在Monorepo中,如果app-a
依赖shared-utils
,它的package.json
会这样写:
// packages/app-a/package.json
{"name": "app-a","dependencies": {"shared-utils": "workspace:*" }
}
workspace:*
这个特殊的版本号,告诉pnpm:“请在当前工作区内寻找一个名为shared-utils
的包,并直接链接到它。”
当你运行pnpm install
时,pnpm会在app-a/node_modules
目录下,创建一个指向packages/shared-utils
真实源文件的符号链接。
这意味着:
- 无需发布,即时更新:当你在
shared-utils
里修改了代码,app-a
会立即感知到这个变化,无需任何版本发布和重装依赖的流程。本地开发调试的体验发生了质的飞跃。 - 单一依赖版本:所有本地包都共享同一个根目录的
node_modules
。pnpm会通过其巧妙的算法,确保整个Monorepo中,同一个第三方依赖(比如React)只有一个版本被安装,从根本上杜绝了版本冲突和“依赖地狱”。
实战:改造我们的“看不见”应用
现在,我们就来把我们之前构建的、分散在不同章节的纯逻辑模块,改造成一个Monorepo。
步骤一:初始化项目结构
mkdir my-invisible-app-monorepo
cd my-invisible-app-monorepo
pnpm init
创建pnpm-workspace.yaml
文件,这是声明一个pnpm工作区的标志:
# pnpm-workspace.yaml
packages:- 'packages/*'
这告诉pnpm,所有在packages/
目录下的子目录,都将被视为工作区内的本地包。
创建packages
目录,并把我们之前的核心逻辑,拆分成独立的包:
/my-invisible-app-monorepo
├── packages/
│ ├── rendering-engine/ (存放vdom, diff, patch等)
│ │ └── package.json
│ ├── state-management/ (存放atom, store等)
│ │ └── package.json
│ └── app-core/ (作为主应用,消费其他包)
│ └── package.json
└── pnpm-workspace.yaml
└── package.json
步骤二:配置各个包的package.json
packages/rendering-engine/package.json
{"name": "@invisible/rendering-engine","version": "1.0.0","main": "dist/index.js", // 假设我们有构建步骤"types": "dist/index.d.ts"
}
packages/state-management/package.json
{"name": "@invisible/state-management","version": "1.0.0","main": "dist/index.js","types": "dist/index.d.ts"
}
packages/app-core/package.json
{"name": "@invisible/app-core","version": "1.0.0","dependencies": {"@invisible/rendering-engine": "workspace:*","@invisible/state-management": "workspace:*"},"scripts": {"start": "node ./src/main.js"}
}
步骤三:安装依赖
回到项目根目录,运行:
pnpm install
pnpm会自动读取所有packages/*/package.json
,安装它们的依赖,并在app-core
的node_modules
下创建指向rendering-engine
和state-management
的符号链接。
步骤四:在app-core
中使用本地包
现在,app-core
可以像消费NPM上的普通包一样,消费我们自己的本地包。
packages/app-core/src/main.js
// 像引用第三方库一样,引用我们自己的本地包
const { createElement, diff } = require('@invisible/rendering-engine');
const { atom, AtomStore } = require('@invisible/state-management');console.log('Successfully imported local packages from workspace!');// ... 你的应用主逻辑 ...
步骤五:统一的脚本命令
我们可以在根目录的package.json
中,使用-r
或--recursive
标志来执行所有子包的脚本,或者用--filter
来指定某个包。
根package.json
{"scripts": {"build": "pnpm --recursive build", // 运行所有包的build脚本"start:app": "pnpm --filter @invisible/app-core start" // 只运行app-core的start脚本}
}
现在,在根目录运行pnpm start:app
,就可以启动我们的主应用了。
第三幕:Monorepo工具链 - Lerna与Changesets
虽然pnpm workspace解决了本地依赖和脚本执行的问题,但对于更复杂的Monorepo管理,比如版本控制和发布流程,我们还需要更专业的工具。
Lerna:老牌的版本与发布管理者
Lerna是一个Monorepo管理工具,它最核心的功能是:
- 版本管理:
lerna version
可以智能地检测自上次发布以来,哪些包发生了变更,并根据你的配置(固定模式或独立模式),自动提升它们的版本号、打上git tag。 - 发布流程:
lerna publish
会将所有版本有变更的包,一键发布到NPM。
现代工作流中,Lerna通常与pnpm workspace结合使用,pnpm负责依赖管理,Lerna负责版本和发布。
Changesets:更现代化的选择
Changesets是Atlassian推出的一个更现代化的Monorepo版本管理工具。它采用了一种更优雅的、基于“意图”的工作流:
- 当你完成一个功能或修复(可能跨越了多个包)后,你运行
pnpm changeset add
。 - 工具会交互式地询问你,哪些包受到了影响,以及这次变更是
patch
(修复)、minor
(功能)还是major
(破坏性变更)。 - 它会生成一个
.md
文件,记录下这次变更的“意图”。 - 在发布时,
pnpm changeset version
会读取所有这些.md
文件,自动计算出每个包的下一个正确版本,并生成更新日志(CHANGELOG)。 - 最后,
pnpm publish -r
(或lerna publish
)将它们发布。
这种工作流将版本决策,分散到了每一次的开发提交中,让发布过程变得更加自动化和可预测。
结论:Monorepo是团队协作的“加速器”
从Polyrepo到Monorepo,不仅仅是代码文件夹的“物理聚合”,更是研发流程和团队协作模式的一次深刻变革。
通过将所有相关的代码置于一个统一的仓库和工具链下,Monorepo为我们带来了:
- 无摩擦的本地开发:
workspace:*
协议消除了本地包之间调试和联动的延迟。 - 强化的代码一致性:统一的构建、测试、Lint和类型检查,保证了整个代码库的高质量。
- 简化的依赖管理:从根本上解决了版本冲突和依赖更新的繁琐工作。
- 高效的跨项目重构:IDE的重构功能(如重命名、文件移动)可以在整个代码库中原子化地完成。
- 透明的代码共享文化:所有代码都在眼前,鼓励了团队成员之间的代码复用和互相学习。
当然,Monorepo也并非银弹。它对构建工具链的要求更高,仓库的体积和历史可能会变得非常庞大。但对于任何需要多个包协同工作、或者期望促进团队内部代码共享的项目来说,它带来的收益,远远超过了它的成本。
核心要点:
- **多仓库(Polyrepo)**模式在依赖管理、原子性提交和代码重构方面存在显著痛点。
- **单体仓库(Monorepo)**通过将多个项目放在一个仓库中,来解决这些问题。
- **
pnpm workspace
**等工具是Monorepo的引擎,它通过符号链接实现了本地包之间的即时联动。 - Lerna和Changesets等专业工具,则进一步解决了Monorepo的版本管理和发布流程的自动化问题。
- Monorepo是一种促进团队协作、提升工程效率的先进代码组织范式。
至此,我们第四部分《性能与工程化》的探索也告一段落了。我们的“看不见”的应用,不仅性能卓越,而且拥有了现代化的、可扩展的工程化架构。
在最后的第五部分**《思想升华与未来》**中,我们将从具体的代码实现,上升到更宏观的设计模式、自动化流程和工程师的职业哲学。敬请期待!