本文會分享一個我在實際工作中遇到的案例,從最開始的需求分析到專案搭建,以及最後落地的架構的整個過程。最終實現的效果是使用mono-repo
實現了跨專案的元件共享。在本文中你可以看到:
- 從接到需求到深入分析並構建架構的整個思考過程。
mono-repo
的簡單介紹。mono-repo
適用的場景分析。- 產出一個可以跨專案共享元件的專案架構。
本文產出的架構模板已經上傳到GitHub,如果你剛好需要一個mono-repo + react的模板,直接clone下來吧:https://github.com/dennis-jiang/mono-repo-demo
需求
需求概況
是這麼個情況,我還是在那家外企供職,不久前我們接到一個需求:要給外國的政府部門或者他的代理機構開發一個可以繳納水電費,順便還能賣賣可樂的網站。主要使用場景是市政廳之類的地方,類似這個樣子:
這張圖是我在網上隨便找的某銀行的圖片,跟我們使用場景有點類似。他有個自助的ATM機,遠處還有人工櫃檯。我們也會有自助機器,另外也會有人工櫃檯,這兩個地方都可以交水電費,汽車罰款什麼的,唯一有個區別是人工那裡除了交各種賬單,還可能會賣點東西,比如口渴了買個可樂,煙癮犯了來包中華。
需求分析
上面只是個概況,要做下來還有很多東西需要細化,櫃員使用的功能和客戶自助使用的功能看起來差不多,細想下來區別還真不少:
- 無論是交賬單還是賣可樂,我們都可以將它視為一個商品,既然賣商品那肯定有上架和下架的功能,也就是商品管理,這個肯定只能做在櫃員端。
- 市政廳人員眾多,也會有上下級關係,普通櫃員可能沒有許可權上/下架,他可能只有售賣許可權,上/下架可能需要經理才能操作,這意味著櫃員介面還需要許可權管理。
- 許可權管理的基礎肯定是使用者管理,所以櫃員介面需要做登陸和註冊。
- 客戶自助介面只能交賬單不能賣可樂很好理解,因為是自助機,旁邊無人值守,如果擺幾瓶可樂,他可能會拿了可樂不付錢。
- 那客戶自助交水電費需要登陸嗎?不需要!跟國內差不多,只需要輸入卡號和姓名等基本資訊就可以查詢到賬單,然後線上信用卡就付了。所以客戶介面不需要登陸和使用者管理。
從上面這幾點分析我們可以看出,櫃員介面會多很多功能,包括商品管理,使用者管理,許可權管理等,而客戶自助介面只能交賬單,其他功能都沒有。
原型設計
基於上面幾點分析,我們的設計師很快設計了兩個介面的原型。
這個是櫃員介面的:
櫃員介面看起來也很清爽,上面一個頭部,左上角顯示了當前機構的名稱,右上角顯示了當前使用者的名字和設定入口。登陸/登出相關功能點選使用者名稱可以看到,商品管理,使用者管理需要點選設定按鈕進行跳轉。
這個是客戶自助介面的:
這個是客戶介面的,看起來基本是一樣的,只是少了使用者和設定那一塊,賣的東西少了可樂,只能交賬單。
技術
現在需求基本已經理清楚了,下面就該我們技術出馬了,進行技術選型和架構落地。
一個站點還是兩個站點?
首先我們需要考慮的一個問題就是,櫃員介面和客戶介面是做在一個網站裡面,還是單獨做兩個網站?因為兩個介面高度相似,所以我們完全可以做在一起,在客戶自助介面隱藏掉右上角的使用者和設定就行了。
但是這裡面其實還隱藏著一個問題:櫃員介面是需要登陸的,所以他的入口其實是登陸頁;客戶介面不需要登陸,他的入口應該直接就是售賣頁。如果將他們做在一起,因為不知道是櫃員使用還是客戶使用,所以入口只能都是登入頁,櫃員直接登陸進入售賣頁,對於客戶可以單獨加一個“客戶自助入口”讓他進入客戶的售賣頁面。但是這樣使用者體驗不好,客戶本來不需要登陸的,你給他看一個登入頁可能會造成困惑,可能需要頻繁求教工作人員才知道怎麼用,會降低整體的工作效率,所以產品經理並不接受這個,要求客戶一進來就需要看到客戶的售賣頁面。
而且從技術角度考慮,現在我們是一個if...else...
隱藏使用者和設定就行了,那萬一以後兩個介面差異變大,客戶介面要求更花哨的效果,就不是簡單的一個if...else...
能搞定的了。所以最後我們決定部署兩個站點,櫃員介面和客戶介面單獨部署到兩個域名上。
元件重複
既然是兩個站點,考慮到專案的可擴充套件性,我們建立了兩個專案。但是這兩個專案的UI在目前階段是如此相似,如果我們寫兩套程式碼,勢必會有很多元件是重複的,比較典型的就是上面的商品卡片,購物車元件等。其實除了上面可以看到這些會重複外,我們往深入想,交個水費,我們肯定還需要使用者輸入姓名,卡號之類的資訊,所以點了水費的卡片後肯定會有一個輸入資訊的表單,而且這個表單在櫃員介面和客戶介面基本是一樣的,除了水費表單外,還有電費表單,罰單表單等等,所以可以預見重複的元件會非常多。
作為一個有追求的工程師,這種重複元件肯定不能靠CV大法來解決,我們得想辦法讓這些元件可以複用。那元件怎麼複用呢?提個公共元件庫嘛,相信很多朋友都會這麼想。我們也是這麼想的,但是公共元件庫有多種組織方式,我們主要考慮了這麼幾種:
單獨NPM包
再建立一個專案,這個專案專門放這些可複用的元件,類似於我們平時用的antd
之類的,建立好後釋出到公司的私有NPM倉庫上,使用的時候直接這樣:
import { Cart } from 'common-components';
但是,我們需要複用的這些元件跟antd
元件有一個本質上的區別:我們需要複用的是業務元件,而不是單純的UI元件。antd
UI元件庫為了保證通用性,基本不帶業務屬性,樣式也是開放的。但是我這裡的業務元件不僅僅是幾個按鈕,幾個輸入框,而是一個完整的表單,包括前端驗證邏輯都需要複用,所以我需要複用的元件其實是跟業務強繫結的。因為他是跟業務強繫結的,即使我將它作為一個單獨的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吧:https://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官網:https://lerna.js.org/
- Yarn workspace: https://classic.yarnpkg.com/en/docs/workspaces/
文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。
歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~
“前端進階知識”系列文章原始碼地址: https://github.com/dennis-jiang/Front-End-Knowledges