使用mono-repo實現跨項目組件共享

本文會分享一個我在實際工作中遇到的案例,從最開始的需求分析到項目搭建,以及最後落地的架構的整個過程。最終實現的效果是使用mono-repo實現了跨項目的組件共享。在本文中你可以看到:

  1. 從接到需求到深入分析並構建架構的整個思考過程。
  2. mono-repo的簡單介紹。
  3. mono-repo適用的場景分析。
  4. 產出一個可以跨項目共享組件的項目架構。

本文產出的架構模板已經上傳到GitHub,如果你剛好需要一個mono-repo + react的模板,直接clone下來吧://github.com/dennis-jiang/mono-repo-demo

需求

需求概況

是這麼個情況,我還是在那家外企供職,不久前我們接到一個需求:要給外國的政府部門或者他的代理機構開發一個可以繳納水電費,順便還能賣賣可樂的網站。主要使用場景是市政廳之類的地方,類似這個樣子:

image-20201224162525774

這張圖是我在網上隨便找的某銀行的圖片,跟我們使用場景有點類似。他有個自助的ATM機,遠處還有人工櫃檯。我們也會有自助機器,另外也會有人工櫃檯,這兩個地方都可以交水電費,汽車罰款什麼的,唯一有個區別是人工那裡除了交各種賬單,還可能會賣點東西,比如口渴了買個可樂,煙癮犯了來包中華。

需求分析

上面只是個概況,要做下來還有很多東西需要細化,櫃員使用的功能和客戶自助使用的功能看起來差不多,細想下來區別還真不少:

  1. 無論是交賬單還是賣可樂,我們都可以將它視為一個商品,既然賣商品那肯定有上架和下架的功能,也就是商品管理,這個肯定只能做在櫃員端。
  2. 市政廳人員眾多,也會有上下級關係,普通櫃員可能沒有許可權上/下架,他可能只有售賣許可權,上/下架可能需要經理才能操作,這意味著櫃員介面還需要許可權管理。
  3. 許可權管理的基礎肯定是用戶管理,所以櫃員介面需要做登陸和註冊。
  4. 客戶自助介面只能交賬單不能賣可樂很好理解,因為是自助機,旁邊無人值守,如果擺幾瓶可樂,他可能會拿了可樂不付錢。
  5. 那客戶自助交水電費需要登陸嗎?不需要!跟中國差不多,只需要輸入卡號和姓名等基本資訊就可以查詢到賬單,然後線上信用卡就付了。所以客戶介面不需要登陸和用戶管理。

從上面這幾點分析我們可以看出,櫃員介面會多很多功能,包括商品管理,用戶管理,許可權管理等,而客戶自助介面只能交賬單,其他功能都沒有。

原型設計

基於上面幾點分析,我們的設計師很快設計了兩個介面的原型。

這個是櫃員介面的:

image-20201224172006928

櫃員介面看起來也很清爽,上面一個頭部,左上角顯示了當前機構的名稱,右上角顯示了當前用戶的名字和設置入口。登陸/登出相關功能點擊用戶名可以看到,商品管理,用戶管理需要點擊設置按鈕進行跳轉。

這個是客戶自助介面的:

image-20201224172649189

這個是客戶介面的,看起來基本是一樣的,只是少了用戶和設置那一塊,賣的東西少了可樂,只能交賬單。

技術

現在需求基本已經理清楚了,下面就該我們技術出馬了,進行技術選型和架構落地。

一個站點還是兩個站點?

首先我們需要考慮的一個問題就是,櫃員介面和客戶介面是做在一個網站裡面,還是單獨做兩個網站?因為兩個介面高度相似,所以我們完全可以做在一起,在客戶自助介面隱藏掉右上角的用戶和設置就行了。

但是這裡面其實還隱藏著一個問題:櫃員介面是需要登陸的,所以他的入口其實是登陸頁;客戶介面不需要登陸,他的入口應該直接就是售賣頁。如果將他們做在一起,因為不知道是櫃員使用還是客戶使用,所以入口只能都是登錄頁,櫃員直接登陸進入售賣頁,對於客戶可以單獨加一個「客戶自助入口」讓他進入客戶的售賣頁面。但是這樣用戶體驗不好,客戶本來不需要登陸的,你給他看一個登錄頁可能會造成困惑,可能需要頻繁求教工作人員才知道怎麼用,會降低整體的工作效率,所以產品經理並不接受這個,要求客戶一進來就需要看到客戶的售賣頁面。

而且從技術角度考慮,現在我們是一個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的基本使用方法網上有很多,我這裡就不啰嗦了,主要說幾個缺點,也是我們沒採用他的原因:

  1. 本質上submodule和主項目是兩個不同的git repo,所以你需要為每個項目創建一套腳手架(程式碼規範,發布腳本什麼的)。
  2. submodule其實只是主項目保存了一個對子項目的依賴鏈接,說明了當前版本的主項目依賴哪個版本的子項目,你需要小心的使用git submodule update來管理這種依賴關係。如果沒有正確使用git submodule update而搞亂了版本的依賴關係,那就呵呵了。。。
  3. 發布的時候需要自己小心處理依賴關係,先發子項目,子項目好了再發布主項目。

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的源碼結構:

image-20201225153108233

我們發現他有個packages文件夾,裡面有四個項目:

  1. react-router:是React-Router的核心庫,處理一些共用的邏輯

  2. react-router-config:是React-Router的配置處理庫

  3. react-router-dom:瀏覽器上使用的庫,會引用react-router核心庫

  4. 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,整個結構長這樣:

image-20201225162905950

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/目錄是空的,根據我們前面的設想,我們需要創建三個項目:

  1. common:共享的業務組件,本身不需要運行,放各種組件就行了。
  2. admin-site:櫃員站點,需要能夠運行,使用create-react-app創建吧
  3. customer-site:客戶站點,也需要運行,還是使用create-react-app創建

創建子項目可以使用lerna的命令來創建:

lerna create <name>

也可以自己手動創建文件夾,這裡common子項目我就用lerna命令創建吧,lerna create common,運行後common文件夾就出現在packages下面了:

image-20201231145959966

這個是使用lerna create默認生成的目錄結構,__test__文件夾下面放得是單元測試內容,lib下面放得是程式碼。由於我是準備用它來放共享組件的,所以我把目錄結構調整了,默認生成的兩個文件夾都刪了,新建了一個components文件夾:

image-20201231150311253

另外兩個可運行站點都用create-react-app創建了,在packages文件夾下運行:

npx create-react-app admin-site; npx create-react-app customer-site;

幾個項目都創建完後,整個項目結構是這樣的:

image-20201231151536018

按照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吧:

image-20201231155954580

看到了我們熟悉的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是一樣的:

image-20201231161945744

然後我們去把admin-site默認的CRA圈圈改成這個水費表單吧:

image-20201231162333590

然後再運行下:

image-20201231162459416

嗯?報錯了。。。如果我說這個錯誤是我預料之中的,你信嗎😜

共享腳手架

仔細看下上面的錯誤,是報在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看出來的。移動過後的項目結構長這樣:

image-20201231165208361

注意CRA項目的啟動腳本在scripts文件夾裡面,所以我們需要稍微修改下admin-site的啟動命令:

// admin-site package.json

{
  "scripts": "node ../../scripts/start.js",
}

現在我們使用yarn start:aSite仍然會報錯,所以我們繼續修改babel的設置。

首先在config/paths裡面添加上我們packages的路徑並export出去:

image-20201231173801079

然後修改webpacka配置,在babel-loader的include路徑裡面添加上這個路徑:

image-20201231173912873

現在再運行下我們的項目就正常了:

image-20210102142340656

最後別忘了,還有我們的customer-site哦,這個處理起來就簡單了,因為前面我們已經調好了整個主項目的結構,我們可以將customer-site的其他依賴都刪了,只保留@mono-repo-demo/common,然後調整下啟動腳本就行了:

image-20210102142635875

這樣客戶站點也可以引入公共組件並啟動了。

發布

最後要注意的一點是,當我們修改完成後,需要發布了,一定要使用lerna publish,他會自動幫我更新依賴的版本號。比如我現在稍微修改了一下水費表單,然後提交:

image-20210102145343033

現在我試著發布一下,運行

lerna publish

運行後,他會讓你選擇新的版本號:

image-20210102150019630

我這裡選擇一個minor,也就是版本號從0.0.0變成0.1.0,然後lerna會自動更新相關的依賴版本,包括:

  1. lerna.json自己版本號升為0.1.0:

    image-20210102150535183

  2. common的版本號變為0.1.0:

    image-20210102150621696

  3. admin-site的版本號也變為0.1.0,同時更新依賴的common為0.1.0:

    image-20210102150722538

  4. 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再發布試試:

image-20210102151332029

在運行下lerna publish,我們發現他會讓你自己一個一個來選子項目的版本,我這裡就可以選擇將common升級為0.2.0,而admin-site只是依賴變了,就可以升級為0.1.1:

image-20210102151752370

具體採用哪種策略,是每個子項目版本都保持一致還是各自版本獨立,大家可以根據自己的項目情況決定。

總結

這個mono-repo工程我已經把程式碼清理了一下,上傳到了GitHub,如果你剛好需要一個mono-repo + react的項目模板,直接clone吧://github.com/dennis-jiang/mono-repo-demo

下面我們再來回顧下本文的要點:

  1. 事情的起源是我們接到了一個外國人交水電費並能賣東西的需求,有櫃員端和客戶自助端。
  2. 經過分析,我們決定將櫃員端和客戶自助端部署為兩個站點。
  3. 為了這兩個站點,我們新建了兩個項目,這樣擴展性更好。
  4. 這兩個項目有很多長得一樣的業務組件,我們需要復用他們。
  5. 為了復用這些業務組件,我們引入了mono-repo的架構來進行項目管理,mono-repo特別適合聯繫緊密的多個項目。
  6. mono-repo最出名的工具是lerna。
  7. lerna可以自動管理各個項目之間的依賴以及node_modules。
  8. 使用lerna bootstrap --hoist可以將子項目的node_modules提升到頂層,解決node_modules重複的問題。
  9. 但是lerna bootstrap --hoist在提升時如果遇到各個子項目引用的依賴版本不一致,會提升使用最多的版本,從而導致少數派那個找不到正確的依賴,發生錯誤。
  10. 為了解決提升時版本衝突的問題,我們引入了yarn workspace,他也會提升用的最多的版本,但是會為少數派保留自己的依賴在自己的node_modules下面。
  11. 我們示例中兩個CRA項目都有自己的腳手架,而common沒有腳手架,我們調整了腳手架,將它挪到了最頂層,從而三個項目可以共享。
  12. 發布的時候使用lerna publish,他會自動更新內部依賴,並更新各個子項目自己的版本號。
  13. 子項目的版本號規則可以在lerna.json裡面配置,如果配置為固定版本號,則各個子項目保持一致的版本,如果配置為independent關鍵字,各個子項目可以有自己不同的版本號。

參考資料

  1. Lerna官網://lerna.js.org/
  2. Yarn workspace: //classic.yarnpkg.com/en/docs/workspaces/

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高品質原創~

「前端進階知識」系列文章://juejin.im/post/5e3ffc85518825494e2772fd

「前端進階知識」系列文章源碼GitHub地址: //github.com/dennis-jiang/Front-End-Knowledges

QR1270