使用mono-repo實現跨專案元件共享

蔣鵬飛發表於2021-01-04

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

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

本文產出的架構模板已經上傳到GitHub,如果你剛好需要一個mono-repo + react的模板,直接clone下來吧:https://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-repomulti-repo就是多個倉庫,上面的git submodule其實就是multi-repo的一種方式,主專案和子專案都是單獨的git倉庫,也就構成了多個倉庫。而mono-repo就是一個大倉庫,多個專案都放在一個git倉庫裡面。現在很多知名開源專案都是採用的mono-repo的組織方式,比如BabelReact ,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-domreact-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.jsonlerna.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-sitecustomer-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-sitecustomer-site3.1.0版本移動到頂層,而common專案下會保留自己4.9.4antd,這樣每個子專案都可以拿到自己需要的依賴了。

yarn workspace使用也很簡單,yarn 1.0以上的版本預設就是開啟workspace的,所以我們只需要在頂層的package.json加一個配置就行:

// 頂層package.json
{
  "workspaces": [
    "packages/*"
  ]
}

然後在lerna.json裡面指定npmClientyarn,並將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-sitepackage.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-sitepackage.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-loaderinclude路徑裡面新增上這個路徑:

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,同時更新依賴的common0.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吧:https://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官網:https://lerna.js.org/
  2. Yarn workspace: https://classic.yarnpkg.com/en/docs/workspaces/

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

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

“前端進階知識”系列文章原始碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

相關文章