使用mono-repo實現跨項目組件共享
- 2021 年 1 月 4 日
- 筆記
- javascript, 前端, 工程化
本文會分享一個我在實際工作中遇到的案例,從最開始的需求分析到項目搭建,以及最後落地的架構的整個過程。最終實現的效果是使用mono-repo實現了跨項目的組件共享。在本文中你可以看到:
- 從接到需求到深入分析並構建架構的整個思考過程。
mono-repo的簡單介紹。mono-repo適用的場景分析。- 產出一個可以跨項目共享組件的項目架構。
本文產出的架構模板已經上傳到GitHub,如果你剛好需要一個mono-repo + react的模板,直接clone下來吧://github.com/dennis-jiang/mono-repo-demo
需求
需求概況
是這麼個情況,我還是在那家外企供職,不久前我們接到一個需求:要給外國的政府部門或者他的代理機構開發一個可以繳納水電費,順便還能賣賣可樂的網站。主要使用場景是市政廳之類的地方,類似這個樣子:
這張圖是我在網上隨便找的某銀行的圖片,跟我們使用場景有點類似。他有個自助的ATM機,遠處還有人工櫃檯。我們也會有自助機器,另外也會有人工櫃檯,這兩個地方都可以交水電費,汽車罰款什麼的,唯一有個區別是人工那裡除了交各種賬單,還可能會賣點東西,比如口渴了買個可樂,煙癮犯了來包中華。
需求分析
上面只是個概況,要做下來還有很多東西需要細化,櫃員使用的功能和客戶自助使用的功能看起來差不多,細想下來區別還真不少:
- 無論是交賬單還是賣可樂,我們都可以將它視為一個商品,既然賣商品那肯定有上架和下架的功能,也就是商品管理,這個肯定只能做在櫃員端。
- 市政廳人員眾多,也會有上下級關係,普通櫃員可能沒有許可權上/下架,他可能只有售賣許可權,上/下架可能需要經理才能操作,這意味著櫃員介面還需要許可權管理。
- 許可權管理的基礎肯定是用戶管理,所以櫃員介面需要做登陸和註冊。
- 客戶自助介面只能交賬單不能賣可樂很好理解,因為是自助機,旁邊無人值守,如果擺幾瓶可樂,他可能會拿了可樂不付錢。
- 那客戶自助交水電費需要登陸嗎?不需要!跟中國差不多,只需要輸入卡號和姓名等基本資訊就可以查詢到賬單,然後線上信用卡就付了。所以客戶介面不需要登陸和用戶管理。
從上面這幾點分析我們可以看出,櫃員介面會多很多功能,包括商品管理,用戶管理,許可權管理等,而客戶自助介面只能交賬單,其他功能都沒有。
原型設計
基於上面幾點分析,我們的設計師很快設計了兩個介面的原型。
這個是櫃員介面的:
櫃員介面看起來也很清爽,上面一個頭部,左上角顯示了當前機構的名稱,右上角顯示了當前用戶的名字和設置入口。登陸/登出相關功能點擊用戶名可以看到,商品管理,用戶管理需要點擊設置按鈕進行跳轉。
這個是客戶自助介面的:
這個是客戶介面的,看起來基本是一樣的,只是少了用戶和設置那一塊,賣的東西少了可樂,只能交賬單。
技術
現在需求基本已經理清楚了,下面就該我們技術出馬了,進行技術選型和架構落地。
一個站點還是兩個站點?
首先我們需要考慮的一個問題就是,櫃員介面和客戶介面是做在一個網站裡面,還是單獨做兩個網站?因為兩個介面高度相似,所以我們完全可以做在一起,在客戶自助介面隱藏掉右上角的用戶和設置就行了。
但是這裡面其實還隱藏著一個問題:櫃員介面是需要登陸的,所以他的入口其實是登陸頁;客戶介面不需要登陸,他的入口應該直接就是售賣頁。如果將他們做在一起,因為不知道是櫃員使用還是客戶使用,所以入口只能都是登錄頁,櫃員直接登陸進入售賣頁,對於客戶可以單獨加一個「客戶自助入口」讓他進入客戶的售賣頁面。但是這樣用戶體驗不好,客戶本來不需要登陸的,你給他看一個登錄頁可能會造成困惑,可能需要頻繁求教工作人員才知道怎麼用,會降低整體的工作效率,所以產品經理並不接受這個,要求客戶一進來就需要看到客戶的售賣頁面。
而且從技術角度考慮,現在我們是一個if...else...隱藏用戶和設置就行了,那萬一以後兩個介面差異變大,客戶介面要求更花哨的效果,就不是簡單的一個if...else...能搞定的了。所以最後我們決定部署兩個站點,櫃員介面和客戶介面單獨部署到兩個域名上。
組件重複
既然是兩個站點,考慮到項目的可擴展性,我們創建了兩個項目。但是這兩個項目的UI在目前階段是如此相似,如果我們寫兩套程式碼,勢必會有很多組件是重複的,比較典型的就是上面的商品卡片,購物車組件等。其實除了上面可以看到這些會重複外,我們往深入想,交個水費,我們肯定還需要用戶輸入姓名,卡號之類的資訊,所以點了水費的卡片後肯定會有一個輸入資訊的表單,而且這個表單在櫃員介面和客戶介面基本是一樣的,除了水費表單外,還有電費表單,罰單表單等等,所以可以預見重複的組件會非常多。
作為一個有追求的工程師,這種重複組件肯定不能靠CV大法來解決,我們得想辦法讓這些組件可以復用。那組件怎麼復用呢?提個公共組件庫嘛,相信很多朋友都會這麼想。我們也是這麼想的,但是公共組件庫有多種組織方式,我們主要考慮了這麼幾種:
單獨NPM包
再創建一個項目,這個項目專門放這些可復用的組件,類似於我們平時用的antd之類的,創建好後發布到公司的私有NPM倉庫上,使用的時候直接這樣:
import { Cart } from 'common-components';
但是,我們需要復用的這些組件跟antd組件有一個本質上的區別:我們需要復用的是業務組件,而不是單純的UI組件。antdUI組件庫為了保證通用性,基本不帶業務屬性,樣式也是開放的。但是我這裡的業務組件不僅僅是幾個按鈕,幾個輸入框,而是一個完整的表單,包括前端驗證邏輯都需要復用,所以我需要復用的組件其實是跟業務強綁定的。因為他是跟業務強綁定的,即使我將它作為一個單獨的NPM包發布出去,公司的其他項目也用不了。一個不能被其他項目共享的NPM包,始終感覺有點違和呢。
git submodule
另一個方案是git submodule,我們照樣為這些共享組件創建一個新的Git項目,但是不發布到NPM倉庫去騷擾別人,而是直接在我們主項目以git submodule的方式引用他。git submodule的基本使用方法網上有很多,我這裡就不啰嗦了,主要說幾個缺點,也是我們沒採用他的原因:
- 本質上
submodule和主項目是兩個不同的git repo,所以你需要為每個項目創建一套腳手架(程式碼規範,發布腳本什麼的)。 submodule其實只是主項目保存了一個對子項目的依賴鏈接,說明了當前版本的主項目依賴哪個版本的子項目,你需要小心的使用git submodule update來管理這種依賴關係。如果沒有正確使用git submodule update而搞亂了版本的依賴關係,那就呵呵了。。。- 發布的時候需要自己小心處理依賴關係,先發子項目,子項目好了再發布主項目。
mono-repo
mono-repo是現在越來越流行的一種項目管理方式了,與之相對的叫multi-repo。multi-repo就是多個倉庫,上面的git submodule其實就是multi-repo的一種方式,主項目和子項目都是單獨的git倉庫,也就構成了多個倉庫。而mono-repo就是一個大倉庫,多個項目都放在一個git倉庫裡面。現在很多知名開源項目都是採用的mono-repo的組織方式,比如Babel,React ,Jest, create-react-app, react-router等等。mono-repo特別適合聯繫緊密的多個項目,比如本文面臨的這種情況,下面我們就進入本文的主題,認真看下mono-repo。
mono-repo
其實我之前寫react-router源碼解析的時候就提到過mono-repo,當時就說有機會單獨寫一篇mono-repo的文章,本文也算是把坑填上了。所以我們先從react-router的源碼結構入手,來看下mono-repo的整體情況,下圖就是react-router的源碼結構:
我們發現他有個packages文件夾,裡面有四個項目:
-
react-router:是
React-Router的核心庫,處理一些共用的邏輯 -
react-router-config:是
React-Router的配置處理庫 -
react-router-dom:瀏覽器上使用的庫,會引用
react-router核心庫 -
react-router-native:支援
React-Native的路由庫,也會引用react-router核心庫
這四個項目都是為react的路由管理服務的,在業務上有很強的關聯性,完成一個功能可能需要多個項目配合才能完成。比如修某個BUG需要同時改react-router-dom和react-router的程式碼,如果他們在不同的Git倉庫,需要在兩個倉庫裡面分別修改,提交,打包,測試,然後還要修改彼此依賴的版本號才能正常工作。但是使用了mono-repo,因為他們程式碼都在同一個Git倉庫,我們在一個commit裡面就可以修改兩個項目的程式碼,然後統一打包,測試,發布,如果我們使用了lerna管理工具,版本號的依賴也是自動更新的,實在是方便太多了。
lerna
lerna是最知名的mono-repo的管理工具,今天我們就要用它來搭建前面提到的共享業務組件的項目,我們目標的項目結構是這個樣子的:
mono-repo-demo/ --- 主項目,這是一個Git倉庫
package.json
packages/
common/ --- 共享的業務組件
package.json
admin-site/ --- 櫃員網站項目
package.json
customer-site/ --- 客戶網站項目
package.json
lerna init
lerna初始化很簡單,先創建一個空的文件夾,然後運行:
npx lerna init
這行命令會幫我創建一個空的packages文件夾,一個package.json和lerna.json,整個結構長這樣:
package.json中有一點需要注意,他的private必須設置為true,因為mono-repo本身的這個Git倉庫並不是一個項目,他是多個項目,所以他自己不能直接發布,發布的應該是packages/下面的各個子項目。
"private": true,
lerna.json初始化長這樣:
{
"packages": [
"packages/*"
],
"version": "0.0.0"
}
packages欄位就是標記你子項目的位置,默認就是packages/文件夾,他是一個數組,所以是支援多個不同位置的。另外一個需要特別注意的是version欄位,這個欄位有兩個類型的值,一個是像上面的0.0.0這樣一個具體版本號,還可以是independent這個關鍵字。如果是0.0.0這種具體版本號,那lerna管理的所有子項目都會有相同的版本號----0.0.0,如果你設置為independent,那各個子項目可以有自己的版本號,比如子項目1的版本號是0.0.0,子項目2的版本號可以是0.1.0。
創建子項目
現在我們的packages/目錄是空的,根據我們前面的設想,我們需要創建三個項目:
common:共享的業務組件,本身不需要運行,放各種組件就行了。admin-site:櫃員站點,需要能夠運行,使用create-react-app創建吧customer-site:客戶站點,也需要運行,還是使用create-react-app創建
創建子項目可以使用lerna的命令來創建:
lerna create <name>
也可以自己手動創建文件夾,這裡common子項目我就用lerna命令創建吧,lerna create common,運行後common文件夾就出現在packages下面了:
這個是使用lerna create默認生成的目錄結構,__test__文件夾下面放得是單元測試內容,lib下面放得是程式碼。由於我是準備用它來放共享組件的,所以我把目錄結構調整了,默認生成的兩個文件夾都刪了,新建了一個components文件夾:
另外兩個可運行站點都用create-react-app創建了,在packages文件夾下運行:
npx create-react-app admin-site; npx create-react-app customer-site;
幾個項目都創建完後,整個項目結構是這樣的:
按照mono-repo的慣例,這幾個子項目的名稱最好命名為@<主項目名稱>/<子項目名稱>,這樣當別人引用你的時候,你的這幾個項目都可以在node_modules的同一個目錄下面,目錄名字就是@<主項目名稱>,所以我們手動改下三個子項目package.json裡面的name為:
@mono-repo-demo/admin-site
@mono-repo-demo/common
@mono-repo-demo/customer-site
lerna bootstrap
上面的圖片可以看到,packages/下面的每個子項目有自己的node_modules,如果將它打開,會發現很多重複的依賴包,這會佔用我們大量的硬碟空間。lerna提供了另一個強大的功能:將子項目的依賴包都提取到最頂層,我們只需要先刪除子項目的node_modules再跑下面這行命令就行了:
lerna bootstrap --hoist
刪除已經安裝的子項目node_modules可以手動刪,也可以用這個命令:
lerna clean
yarn workspace
lerna bootstrap --hoist雖然可以將子項目的依賴提升到頂層,但是他的方式比較粗暴:先在每個子項目運行npm install,等所有依賴都安裝好後,將他們移動到頂層的node_modules。這會導致一個問題,如果多個子項目依賴同一個第三方庫,但是需求的版本不同怎麼辦?比如我們三個子項目都依賴antd,但是他們的版本不完全一樣:
// admin-site
"antd": "3.1.0"
// customer-site
"antd": "3.1.0"
// common
"antd": "4.9.4"
這個例子中admin-site和customer-site需要的antd版本都是3.1.0,但是common需要的版本卻是4.9.4,如果使用lerna bootstrap --hoist來進行提升,lerna會提升用的最多的版本,也就是3.1.0到頂層,然後把子項目的node_modules裡面的antd都刪了。也就是說common去訪問antd的話,也會拿到3.1.0的版本,這可能會導致common項目工作不正常。
這時候就需要介紹yarn workspace 了,他可以解決前面說的版本不一致的問題,lerna bootstrap --hoist會把所有子項目用的最多的版本移動到頂層,而yarn workspace 則會檢查每個子項目裡面依賴及其版本,如果版本不一樣則會留在子項目自己的node_modules裡面,只有完全一樣的依賴才會提升到頂層。
還是以上面這個antd為例,使用yarn workspace的話,會把admin-site和customer-site的3.1.0版本移動到頂層,而common項目下會保留自己4.9.4的antd,這樣每個子項目都可以拿到自己需要的依賴了。
yarn workspace使用也很簡單,yarn 1.0以上的版本默認就是開啟workspace的,所以我們只需要在頂層的package.json加一個配置就行:
// 頂層package.json
{
"workspaces": [
"packages/*"
]
}
然後在lerna.json裡面指定npmClient為yarn,並將useWorkspaces設置為true:
// lerna.json
{
"npmClient": "yarn",
"useWorkspaces": true
}
使用了yarn workspace,我們就不用lerna bootstrap來安裝依賴了,而是像以前一樣yarn install就行了,他會自動幫我們提升依賴,這裡的yarn install無論在頂層運行還是在任意一個子項目運行效果都是一樣的。
啟動子項目
現在我們建好了三個子項目,要啟動CRA子項目,可以去那個目錄下運行yarn start,但是頻繁切換文件夾實在是太麻煩了。其實有了lerna的幫助我們可以直接在頂層運行,這需要用到lerna的這個功能:
lerna run [script]
比如我們在頂層運行了lerna run start,這相當於去每個子項目下面都去執行yarn run start或者npm run start,具體是yarn還是npm,取決於你在lerna.json裡面的這個設置:
"npmClient": "yarn"
如果我只想在其中一個子項目運行命令,應該怎麼辦呢?加上--scope就行了,比如我就在頂層的package.json裡面加了這麼一行命令:
// 頂層package.json
{
"scripts": {
"start:aSite": "lerna --scope @mono-repo-demo/admin-site run start"
}
}
所以我們可以直接在頂層運行yarn start:aSite,這會啟動前面說的管理員站點,他其實運行的命令還是lerna run start,然後加了--scope來指定在管理員子項目下運行,@mono-repo-demo/admin-site就是我們管理員子項目的名字,是定義在這個子項目的package.json裡面的:
// 管理員子項目package.json
{
"name": "@mono-repo-demo/admin-site"
}
然後我們實際運行下yarn start:aSite吧:
看到了我們熟悉的CRA轉圈圈,說明到目前為止我們的配置還算順利,哈哈~
創建公共組件
現在項目基本結構已經有了,我們建一個公共組件試一下效果。我們就用antd創建一個交水費的表單吧,也很簡單,就一個姓名輸入框,一個查詢按鈕。
// packages/common/components/WaterForm.js
import { Form, Input, Button } from 'antd';
const layout = {
labelCol: {
span: 8,
},
wrapperCol: {
span: 16,
},
};
const tailLayout = {
wrapperCol: {
offset: 8,
span: 16,
},
};
const WaterForm = () => {
const onFinish = (values) => {
console.log('Success:', values);
};
const onFinishFailed = (errorInfo) => {
console.log('Failed:', errorInfo);
};
return (
<Form
{...layout}
name="basic"
initialValues={{
remember: true,
}}
onFinish={onFinish}
onFinishFailed={onFinishFailed}
>
<Form.Item
label="姓名"
name="username"
rules={[
{
required: true,
message: '請輸入姓名',
},
]}
>
<Input />
</Form.Item>
<Form.Item {...tailLayout}>
<Button type="primary" htmlType="submit">
查詢
</Button>
</Form.Item>
</Form>
);
};
export default WaterForm;
引入公共組件
這個組件寫好了,我們就在admin-site裡面引用下他,要引用上面的組件,我們需要先在admin-site的package.json裡面將這個依賴加上,我們可以去手動修改他,也可以使用lerna命令:
lerna add @mono-repo-demo/common --scope @mono-repo-demo/admin-site
這個命令效果跟你手動改package.json是一樣的:
然後我們去把admin-site默認的CRA圈圈改成這個水費表單吧:
然後再運行下:
嗯?報錯了。。。如果我說這個錯誤是我預料之中的,你信嗎😜
共享腳手架
仔細看下上面的錯誤,是報在WaterForm這個組件裡面的,錯誤資訊是說:jsx語法不支援,最後兩行還給了個建議,叫我們引入babel來編譯。這些都說明了一個同問題:babel的配置對common子項目沒有生效。這其實是預料之中的,我們的admin-site之所以能跑起來是因為CRA幫我們配置好了這些腳手架,而common這個子項目並沒有配置這些腳手架,自然編譯不了。
我們這幾個子項目都是React的,其實都可以共用一套腳手架,所以我的方案是:將CRA的腳手架全部eject出來,然後手動挪到頂層,讓三個子項目共享。
首先我們到admin-site下面運行:
yarn eject
這個命令會將CRA的config文件夾和scripts文件夾彈出來,同時將他們的依賴添加到admin-site的package.json裡面。所以我們要乾的就是手動將config文件夾和scripts文件夾移動到頂層,然後將CRA添加到package.json的依賴也移到最頂層,具體CRA改了package.json裡面的哪些內容可以通過git看出來的。移動過後的項目結構長這樣:
注意CRA項目的啟動腳本在scripts文件夾裡面,所以我們需要稍微修改下admin-site的啟動命令:
// admin-site package.json
{
"scripts": "node ../../scripts/start.js",
}
現在我們使用yarn start:aSite仍然會報錯,所以我們繼續修改babel的設置。
首先在config/paths裡面添加上我們packages的路徑並export出去:
然後修改webpacka配置,在babel-loader的include路徑裡面添加上這個路徑:
現在再運行下我們的項目就正常了:
最後別忘了,還有我們的customer-site哦,這個處理起來就簡單了,因為前面我們已經調好了整個主項目的結構,我們可以將customer-site的其他依賴都刪了,只保留@mono-repo-demo/common,然後調整下啟動腳本就行了:
這樣客戶站點也可以引入公共組件並啟動了。
發布
最後要注意的一點是,當我們修改完成後,需要發布了,一定要使用lerna publish,他會自動幫我更新依賴的版本號。比如我現在稍微修改了一下水費表單,然後提交:
現在我試著發布一下,運行
lerna publish
運行後,他會讓你選擇新的版本號:
我這裡選擇一個minor,也就是版本號從0.0.0變成0.1.0,然後lerna會自動更新相關的依賴版本,包括:
-
lerna.json自己版本號升為0.1.0: -
common的版本號變為0.1.0: -
admin-site的版本號也變為0.1.0,同時更新依賴的common為0.1.0: -
customer-site的變化跟admin-site是一樣的。
independent version
上面這種發布策略,我們修改了common的版本,admin-site的版本也變成了一樣的,按理來說,這個不是必須的,admin-site只是更新依賴的common版本,自己的版本不一定是升級一個minor,也許只是一個patch。這種情況下,admin-site的版本要不要跟著變,取決於lerna.json裡面的version配置,前面說過了,如果它是一個固定的指,那所有子項目版本會保持一致,所以admin-site版本會跟著變,我們將它改成independent就會不一樣了。
// lerna.json
{
"version": "independent"
}
然後我再改下common再發布試試:
在運行下lerna publish,我們發現他會讓你自己一個一個來選子項目的版本,我這裡就可以選擇將common升級為0.2.0,而admin-site只是依賴變了,就可以升級為0.1.1:
具體採用哪種策略,是每個子項目版本都保持一致還是各自版本獨立,大家可以根據自己的項目情況決定。
總結
這個mono-repo工程我已經把程式碼清理了一下,上傳到了GitHub,如果你剛好需要一個mono-repo + react的項目模板,直接clone吧://github.com/dennis-jiang/mono-repo-demo
下面我們再來回顧下本文的要點:
- 事情的起源是我們接到了一個外國人交水電費並能賣東西的需求,有櫃員端和客戶自助端。
- 經過分析,我們決定將櫃員端和客戶自助端部署為兩個站點。
- 為了這兩個站點,我們新建了兩個項目,這樣擴展性更好。
- 這兩個項目有很多長得一樣的業務組件,我們需要復用他們。
- 為了復用這些業務組件,我們引入了
mono-repo的架構來進行項目管理,mono-repo特別適合聯繫緊密的多個項目。 mono-repo最出名的工具是lerna。lerna可以自動管理各個項目之間的依賴以及node_modules。- 使用
lerna bootstrap --hoist可以將子項目的node_modules提升到頂層,解決node_modules重複的問題。 - 但是
lerna bootstrap --hoist在提升時如果遇到各個子項目引用的依賴版本不一致,會提升使用最多的版本,從而導致少數派那個找不到正確的依賴,發生錯誤。 - 為了解決提升時版本衝突的問題,我們引入了
yarn workspace,他也會提升用的最多的版本,但是會為少數派保留自己的依賴在自己的node_modules下面。 - 我們示例中兩個CRA項目都有自己的腳手架,而
common沒有腳手架,我們調整了腳手架,將它挪到了最頂層,從而三個項目可以共享。 - 發布的時候使用
lerna publish,他會自動更新內部依賴,並更新各個子項目自己的版本號。 - 子項目的版本號規則可以在
lerna.json裡面配置,如果配置為固定版本號,則各個子項目保持一致的版本,如果配置為independent關鍵字,各個子項目可以有自己不同的版本號。
參考資料
- Lerna官網://lerna.js.org/
- Yarn workspace: //classic.yarnpkg.com/en/docs/workspaces/
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高品質原創~
「前端進階知識」系列文章://juejin.im/post/5e3ffc85518825494e2772fd
「前端進階知識」系列文章源碼GitHub地址: //github.com/dennis-jiang/Front-End-Knowledges

