工程化(二):為什么你的下一個項目應該使用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是一種促進團隊協作、提升工程效率的先進代碼組織范式。
至此,我們第四部分《性能與工程化》的探索也告一段落了。我們的“看不見”的應用,不僅性能卓越,而且擁有了現代化的、可擴展的工程化架構。
在最后的第五部分**《思想升華與未來》**中,我們將從具體的代碼實現,上升到更宏觀的設計模式、自動化流程和工程師的職業哲學。敬請期待!