React Native工程Monorepo改造實踐

雲音樂技術團隊發表於2022-12-20
圖片來自:https://unsplash.com
本文作者: ddx

背景

目前雲音樂內有多個RN收銀臺場景分佈在不同的工程,比如頁面收銀臺,浮層收銀臺,個性收銀臺等,後續可能還會有別的收銀臺場景。

那在開發過程中存在的問題就是每個收銀臺的核心邏輯如商品展示、支付方式展示、下單購買等邏輯都大致相同,而每次有修改或者新增需求的時候都需要開發多次,重複程式碼較多效率低下。

雖然可以透過發npm包的形式複用程式碼,但是有些元件和程式碼塊不太好抽成包,還會帶來除錯麻煩,發版等問題。所以為了提高程式碼複用,提高開發效率,我們希望能夠在一個倉庫內包含多個工程,也就是Monorepo形式。

Monorepo

什麼是Monorepo

Monorepo是一種將多個專案的程式碼集中在同一個倉庫中的軟體開發策略,與之對立的是傳統的MultiRepo策略,即每個專案在一個單獨的倉庫進行管理。
Monorepo vs MultiRepo
目前像社群內一些著名的開源專案Babel、React和Vue等都是用這種策略來管理程式碼。

Monorepo解決的問題

要想知道Monorepo解決了哪些問題與其優勢,我們先來看下MultiRepo存在的問題。

當我們在MultiRepo下兩個工程之前需要複用一些程式碼時,往往會採用抽成npm包的形式。但當npm包有改動時我們需要做以下事情:

  1. 修改npm包程式碼,透過npm link與兩個工程除錯
  2. 除錯完成後釋出新版本
  3. 兩個工程升級npm包新版本,再進行釋出

整個流程可以看出還是比較繁瑣的,那如果是在Monorepo下我們可以將公共部分抽成一個workspace,我們的兩個工程分別也是workspace可以直接引用公共workspace的程式碼,工具會幫我們管理這些依賴關係,開發過程中除錯起來也非常方便,而且不涉及到發包,版本依賴等,公共部分程式碼改動完成後兩個工程部署即可。

從上述可以看出Monorepo主要有程式碼複用容易除錯方便簡化依賴管理等優點,這也是我們選擇這個方案的原因。

當然Monorepo也有一些缺點,比如:倉庫體積大、工程許可權不好控制等。所以不管是Monorepo還是MultiRepo都不是完美的方案,只要能解決當下的問題就是好方案。

Monorepo的工具

目前業界最常見的實現monorepo工具和方案有lerna、yarn workspace和pnpm等。

Lerna

lerna是一個透過使用git和npm來最佳化多包倉庫管理工作流的工具,多用於多個npm包相互依賴的大型前端工程,提供了許多CLI命令幫助開發者簡化從npm開發,除錯到發版的整個流程。但是目前已官宣停止維護。

Pnpm

pnpm是一個新型的依賴包管理工具,並支援workspace功能,它的優勢主要是透過全域性儲存和硬連結來來磁碟空間並提升安裝速度,透過軟連結來解決幻影依賴問題。但是RN的構建工具metro對於符號連結的解析還存在問題需要改造,成本較大。

Yarn workspace

yarn workspace是yarn提供的Menorepo依賴管理機制,是一個底層的工具,用於在倉庫根目錄下管理多個package的依賴,天然支援hoist功能,安裝依賴時會將packages中相同的依賴提升到根目錄,減少重複依賴安裝。workspace之間的引用在依賴安裝時透過yarn link建立軟鏈,程式碼修改時可以在依賴其的workspace中實時生效,除錯方便。

通常業界主流方案是lerna + yarn worksapce,lerna負責釋出和版本升級,yarn workspace負責依賴管理。因為我們的RN工程是頁面工程,不涉及到發npm包,而且需要依賴提升的功能(這個後面會說到),所以最終採用yarn worspace方案。

Metro

在工程改造之前,我們先了解下ReactNative的構建工具Metro。

Metro在構建過程中主要會經歷三個階段:

  1. Resolution:此階段Metro會從入口檔案出發分析所依賴的模組生成一個所有模組的依賴圖,主要是使用jest-haste-map這個包做依賴分析。這個階段和Transformation階段是並行的;
  2. Transformation:此階段主要是將模組程式碼轉換成目標平臺可識別的格式;
  3. Serialization:此階段主要是將Transform後的模組進行序列化,然後組合這些模組生成一個或多個Bundle

jest-haste-map是單元測試框架Jest的其中一個包,主要用來獲取監聽的所有檔案及其依賴關係。

工程改造

接下來就是對工程的改造,首先我們將兩個RN工程放在一個工程下,並按照yarn workspace的方式進行配置,然後透過腳手架(這裡使用的是公司內部自研的腳手架)分別建立app-a和app-b兩個RN工程,如下所示

rn-mono
|-- apps
  |-- app-a
  |-- app-b
|-- package.json
// package.json
{
  ...
  "workspaces": {
    "packages": [
      "apps/*"
    ]
  },
  "private": true
}

接著我們執行

yarn install

發現packages中相同的依賴都會安裝在根目錄下的node_modules中,接著我們用如下啟動app-a或app-b

yarn workspace app-a run dev

這時如果你的app-a工程中的dev啟動命令是用相對路徑的方式可能會出現命令找不到的情況,比如

// app-a/package.json
{
  // 這裡的react-native是安裝在了根目錄,所以會找不到命令,需要修改下路徑
  "script": {
    "dev": "node ./node_modules/react-native/local-cli/cli.js start"
  }
}

那如果是呼叫./node_modules/.bin中的命令則不需要,因為在安裝依賴的時候packages中.bin中的命令會有個軟鏈指向根目錄下./node_modules/.bin中的命令。啟動成功後,這時開啟頁面會報如下錯誤:

Untitled

這是因為jest-haste-map在做依賴分析時透過metro.config.js中的watcherFolders配置項來指定需要監聽變化的檔案目錄。

Untitled

watcherFolders預設值為工程根目錄,此時也就是app-a中目錄,但是我們的模組都是安裝在根目錄下,所以會找不到。我們需要修改下metro.config.js中watcherFolders

// app-a/metro.config.js

const path = require('path');

module.exports = {
  watchFolders: [path.resolve(__dirname, '../../node_modules')],
};

修改完成後我們重新啟動,再開啟頁面後發現已經可以正常開啟了,同樣的方式app-b也可以正常執行。

但是我們對工程進行monorepo改造的目的是為了抽離公共元件,複用程式碼。所以我們在根目錄下建立個common的資料夾來存放公共部分,此時根目錄下的pacage.json中的packages和apps裡每個app的metro.config.js中watchFolder配置都需要加入common

rn-mono
|-- common
    |-- package.json
|-- apps
    |-- app-a
    |-- app-b
|-- package.json
// package.json
{
  ...
  "workspaces": {
    "packages": [
        "apps/*",
        "common"
      ],
  },
  "private": true
}

// apps/app-a/metro.config.js
const path = require('path');

module.exports = {
  watchFolders: [path.resolve(__dirname, '../../node_modules'), path.resolve(__dirname, '../../common')],
};

接著在common中新增個Button元件,package.json中新增相應的依賴,版本要和apps中對應依賴的版本保持一致

{
  ...
  "dependencies": {
    "react": "16.8.6",
    "react-native": "0.60.5",
  },
}

然後yarn install重新安裝下,這時在根目錄的node_modules下就可以看到common模組軟鏈到了common目錄,所以在app-a中引入common時就可以像npm包一樣直接引入,同樣app-b也可以。

import common from 'common';

到這裡我們RN工程的monorepo改造也基本完成了。

依賴提升

這裡解釋下為什麼需要依賴提升。

我們先來看下取消依賴提升會有什麼問題,可以在根目錄中的package.json中nohoist配置來指定不需要提升安裝到根目錄的模組


{
  ...
  "workspaces": {
    "packages": [
        "apps/*",
        "common"
      ],
    "nohoist": ["**react**"],
  },
  "private": true
}

然後重新yarn install,啟動app-a後會發現報如下錯誤

Untitled

這是因為有些模組jest-haste-map在做依賴分析生成dependency graph時發現在兩個不同的目錄下會產生命名衝突,導致報錯。所以我們需要依賴提升,將所用到的相同依賴安裝到根目錄,這樣只會安裝一次。

相同依賴的版本保持一致

雖然有了依賴提升但如果每個packages中相同依賴的版本不一致,同樣會導致相同的依賴會安裝多次的情況出現,根目錄和對應的package中都會有。這種情況除了會產生以上問題外還有可能產生其他潛在的問題,比如依賴客戶端的第三方模組,如果存在多個版本在bundle執行時會多次註冊元件導致元件註冊失敗,在呼叫時會發生找不到元件的報錯。

雖然可以在metro中配置blacklistRE和extraNodeModules來表明要讀取哪個位置的依賴,但是這種方式並不通用,每次在引入新的依賴時都要去配置下較為繁瑣。所以我們需要將每個packages中的依賴版本保持一致。

人為的去約定這個規則肯定是不安全的,可以開發一個依賴版本的lint檢測工具,在提交程式碼的時候做強制性的檢測。

我們最終的方案是開發一個檢測指令碼結合gitlab-ci在分支程式碼push的時候檢測,未透過則不允許push程式碼來避免風險。

// .gitlab-ci.yml
test-dev-version:
  stage: test
  before_script:
    - npm install --registry http://rnpm.hz.netease.com
  script:
    - npm run depVerLint
  only:
    changes:
      - "package.json"
      - "packages/**/package.json"

Untitled

工程遷移過渡

如果是將多個正在快速迭代的工程遷移到一個Monorepo倉庫時,肯定會遇到存量開發分支程式碼同步問題。比如我們要將工程A遷移到新倉庫,如果我們只是基於master分支將程式碼copy到新工程,並在改造開發過程中還有組內其他同學也在基於master拉取分支做開發,並在你改造完成前開發完成合併到了master,此時你新工程的程式碼是落後的,要想同步只能手動copy改動的程式碼,很容易出錯。為了解決這個問題我們可以使用git subtree。
git subtree允許將一個倉庫作為子倉庫巢狀在另一個倉庫裡,所以這裡我們可以將工程A作為一個子工程新增到Monorepo新工程對應的packages目錄下,如果有更新可以直接使用pull進行同步。

# 新增
git subtree add --prefix=apps/app-a https://github.com/xxxx/app-a.git master --squash

# 更新
git subtree pull --prefix=apps/app-a https://github.com/xxxx/app-a.git master --squash

對於新工程或者新的開發分支就可以直接此工程下進行開發了。

構建

由於我們的構建機還不支援yarn,所以直接使用yarn workspace的命令是有問題的。目前的做法是將yarn作為devDependency,然後在根目錄下建立個指令碼檔案,將每個package的構建命令收斂在一起。結合yarn workspace的命令,這樣只需要在構建時傳入不同的package name即可。

## scripts/build.sh

PLATFORM=$1
PROJECT=$2
EXEC_PARAMS=${@:2}
YARN="${PWD}/node_modules/.bin/yarn"

...

echo "start yarn install"
${YARN} cache clean
${YARN} install

echo "start build"
echo "${YARN} workspace ${PROJECT} run build:${PLATFORM} ${EXEC_PARAMS}"
${YARN} workspace ${PROJECT} run build:${PLATFORM} ${EXEC_PARAMS}
// package.json
{
  ...
  "workspaces": {
    "packages": [
      "apps/*"
    ],
  },
  "private": true,
  "scripts": {
    "build": "./script/build.sh"
  },
}

比如對app-a進行構建,就可以


npm run build ios app-a

## 實際上執行的是yarn workspace app-a run build:ios

總結

至此對React Native工程的menorepo改造基本完成了,對於多個功能類似的工程採用Monorepo的管理方式確實會方便程式碼複用和除錯,提高我們的開發效率。如果公司內部其餘場景有類似的需求,未來規劃可以將其沉澱出一個腳手架。

目前對於h5工程的Monorepo方案已經較為成熟了,但是對RN工程來說由於構建機制不同無法完全適用,可參考的資料也較少。本文也是透過實踐記錄了一些踩坑經驗,如果你有更好的實踐,歡迎留言一起討論。

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章