Metro拆包工作原理與實戰

xiangzhihong發表於2022-06-06

一、背景

觸過RN的同學都知道,熱更新作為RN最大的特點之一,可以讓開發者隨時上線新的迭代以及修復線上Bug。在上一篇文章我們聊了一下熱更新平臺搭建,今天來我們聊聊熱更新中的拆包環節。

熱更新和拆包都是大家聊得比較多的話題,通常一個聊得比較多的技術話題都會有一套成熟的技術方案,比如熱更新平臺就有 CodePush 這樣的成熟方案,但拆包卻沒有一套大家都公認成熟的方案。不過,市面上支援拆包的方案有react-native-multibundler、攜程的moles-packer 還有58同城的metro-code-split,由於前兩種已經停止更新,所以不做特別的介紹。

眾所周知,Facebook 開源的 Metro 打包工具,本身並沒有拆包功能,它的主要功能是將 JavaScript 程式碼打包成一個 Bundle 檔案,而且 Metro 也不支援第三方外掛,所以社群也沒有第三方拆包外掛。

不過,我們在閱讀 Metro 原始碼的時候,發現了一個可配置的函式 customSerializer,從而找到了不入侵 Metro 原始碼,通過配置的方式給 Metro 寫第三方外掛的方法。有了 Metro 的 customSerializer 方法後,現在我們也可以給 Metro 來寫外掛了,通過外掛來提供單獨拆包能力。

二、metro-code-split基本使用

metro-code-split是58同城技術團隊開發的支援RN拆包的外掛,目前支援最新的0.66.2版本,相關的文章介紹可以參考:58RN 頁面秒開方案與實踐

接下來,我們看一下如何在現有的專案中接入metro-code-split。首先,我們在專案中安裝metro-code-split外掛。

npm i metro-code-split -D
//或者
yarn add metro-code-split -D

然後,在package.json配置檔案中新增如下指令碼:

  "scripts": {
    "start": "mcs-scripts start -p 8081",
    "build:dllJson": "mcs-scripts build -t dllJson -od public/dll",
    "build:dll": "mcs-scripts build -t dll -od public/dll",
    "build": "mcs-scripts build -t busine -e index.js"
  }

指令碼的具體含義如下:

  • start:啟動本地除錯服務;
  • build:dllJson:構建公共包的模組檔案;
  • build:dll:構建公共包;
  • build:構建業務包和按需載入包。

如果是開發環境,上述的配置指令碼需要NODE_ENV=xxx引數,修改後如下所示。

  "scripts": {
    "start": "NODE_ENV=production react-native start --port 8081",
    "build:dllJson": "NODE_ENV=production react-native bundle --platform ios --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.ios.json --dev false & NODE_ENV=production react-native bundle --platform android --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.android.json --dev false",
    "build:dll": "NODE_ENV=production react-native bundle --platform ios --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.ios.bundle --dev false & NODE_ENV=production react-native bundle --platform android --entry-file node_modules/.cache/metro-code-split/dll-entry.js --bundle-output public/dll/_dll.android.bundle --dev false",
    "build": "NODE_ENV=production react-native bundle --platform ios --entry-file index.js --bundle-output dist/buz.ios.bundle --dev false & NODE_ENV=production react-native bundle --platform android --entry-file index.js --bundle-output dist/buz.android.bundle --dev false"
  }

接下來,修改metro.config.js檔案的配置如下:

  
const Mcs = require('metro-code-split')

// 拆包的配置
const mcs = new Mcs({
  output: {
    // 配置你的 CDN 的 BaseURL 
    publicPath: 'https://static001.geekbang.org/resource/rn',
  },
  dll: {
    entry: ['react-native', 'react'], // 要內建的 npm 庫
    referenceDir: './public/dll', // 拆包的路徑
  },
  dynamicImports: {}, // dynamic import 是預設開啟的
})

// 業務的 metro 配置
const busineConfig = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true,
      },
    }),
  },
}

// Dynamic Import 在本地和線上環境的實現是不同的
module.exports = process.env.NODE_ENV === 'production' ? mcs.mergeTo(busineConfig) : busineConfig

這裡有兩個拆包的引數需要注意:一個是 publicPath,它是用於配置線上環境中,按需載入包的根路徑的。另一個要注意的引數是 dll,它用於配置需要內建 npm 庫。

通常在一個混合開發的 React Native 應用中,“react”和 “react-native” 這兩個包基本上不會變動,所以你可以把這兩個 npm 庫拆到一個公共包中,這個公共包只能跟隨 App 發版更新。而其他的業務程式碼或者第三方庫,比如 “reanimated”,這些程式碼變動相對頻繁,就可以都跟著進行業務包進行整合,方便動態更新。

配置完成 metro-code-split 之後,如何使用 metro-code-split 進行拆包呢?metro-code-split 支援三類包的拆分,包括公共包、業務包和按需載入包。

公共包

當你在 dll 配置項中填寫了 “react”和 “react-native”之後,每次打包時, “react”和 “react-native”都會被當作公共包來處理。

  dll: {
    entry: ['react-native', 'react'], // 要內建的 npm 庫
    referenceDir: './public/dll', // 拆包的路徑
  },

接下來,直接執行yarn build:dll命令就可以把公共包拆出來。執行完成後,你再檢視public/dll目錄,你會發現該目錄下面多了兩個檔案,分別是 _dll.android.bundle 和 _dll.ios.bundle,這兩個檔案就是整合了“react”和“react-native”所有程式碼的公共包。

如果想要檢視公共包中包含的模組,可以使用下面的命令:

yarn build:dllJson

執行上述命令後,你可以找到 _dll.android.json 和 _dll.ios.json 兩個檔案,這兩個包含了 “react”和“react-native”依賴的所有模組,如下。


[
  "__prelude__", // 框架預製模組
  "require-node_modules/react-native/Libraries/Core/InitializeCore.js", // react-native 初始化模組
  "node_modules/@babel/runtime/helpers/createClass.js", // babel 的類模組
  "node_modules/react-native/index.js", // react-native 入口模組
  "node_modules/metro-runtime/src/polyfills/require.js", // require 執行時模組 
  "node_modules/react/index.js" // react 模組
]

_dll.json 記錄了所有的公共模組,_dll.bundle 包含所有公共模組程式碼,比如管理 React Native 全域性變數的框架預製模組 __prelude__、管理初始化的 InitializeCore 模組、管理 babel、require 的模組,以及 react 和 react-native 框架的入口模組。

業務包和按需載入包

當你拿到內建包後,除了“react”和“react-native”的內建程式碼以外,其他所有程式碼都歸屬於業務包,但有一類檔案例外,就是按需載入模組。不過因為業務包和按需載入包的耦合性很強,按需載入包沒辦法脫離業務包進行獨立打包,所以接下來我會把業務包和按需載入包一起介紹。

通常,你引入普通業務模組,使用的是 import * from "xxx" ,那麼該模組的程式碼都會直接打到業務包中。但在引入按需載入業務模組時,使用的是 import("xxx") 引入的,那麼該模組程式碼會直接打到按需載入包中。比如,有下面一段程式碼:


import React, {lazy, Suspense} from 'react';
import {
  Text,
} from 'react-native';
import {NavigationContainer} from '@react-navigation/native';
import {
  createNativeStackNavigator,
} from '@react-navigation/native-stack';
import {Views, RootStackParamList} from './types';
import Main from './component/Main';

const Stack = createNativeStackNavigator<RootStackParamList>();

const Foo = lazy(() => import('./component/Foo'));
const Bar = lazy(() => import('./component/Bar'));

export default function App() {
  return (
    <Suspense fallback={<Text>Loading...</Text>}>
      <NavigationContainer>
        <Stack.Navigator initialRouteName={Views.Main}>
          <Stack.Screen name={Views.Main} component={Main} />
          <Stack.Screen name={Views.Foo} component={Foo} />
          <Stack.Screen name={Views.Bar} component={Bar} />
        </Stack.Navigator>
      </NavigationContainer>
    </Suspense>
  );
}

可以看到,Main 元件是通過 import * from "xxx" 引入的,它屬於普通的業務模組;而 Foo 元件和 Bar 元件是通過 import("xxx") 引入的,它們屬於按需載入的業務模組。當我們完成程式碼的編寫後,使用如下命令就可以生成業務包和按需載入包。

yarn build

構建完成後,業務包和按需載入包會放在 dist 目錄下,其中 buz.android.bundlebuz.ios.bundle 就是業務包,chunks 目錄下以 MD5 值開頭的包就是按需載入包。

dist
├── buz.android.bundle
├── buz.ios.bundle
└── chunks
    ├── 22b3a0e5af84f7184abd.bundle
    └── 479c3b2dc4e8fef12a34.bundle

可以看到,通過 yarn build:dllyarn build,我們就完成了公共包、業務包、按需載入包的構建。

附件:Mcs 預設配置引數

三、拆包原理

3.1 Metro 打包流程

metro是一種RN的打包工具,現在我們也可以使用它來進行拆包,metro 打包流程分為以下幾個步驟:

  1. Resolution:Metro 需要從入口點構建所需的所有模組的圖,要從另一個檔案中找到所需的檔案,需要使用 Metro 解析器。在實際開發中,這個階段與Transformation 階段是並行的。
  2. Transformation:所有模組都要經過 Transformation 階段,Transformation 負責將模組轉換成目標平臺可以理解的語法格式(如 React Naitve)。模組的轉換是基於擁有的核心數量來決定的。
  3. Serialization:所有模組一經轉換就會被序列化,Serialization 會組合這些模組來生成一個或多個包,包就是將模組組合成一個 JavaScript 檔案的包,序列化的時候提供了一些列的方法讓開發者自定義一些內容,比如模組 id,模組過濾等。

開啟Metro庫的createModuleIdFactory程式碼,路徑為node_modules/metro/src/lib/createModuleIdFactory.js ,可以看到如下一段程式碼。

function createModuleIdFactory() {
  const fileToIdMap = new Map();
  let nextId = 0;
  return path => {
    let id = fileToIdMap.get(path);

    if (typeof id !== "number") {
      id = nextId++;
      fileToIdMap.set(path, id);
    }

    return id;
  };
}

module.exports = createModuleIdFactory;

上述程式碼的邏輯是:如果查到 map 裡沒有記錄這個模組則 id 自增,然後將該模組記錄到 map 中,所以從這裡可以看出,官方程式碼生成 moduleId 的規則就是自增,所以這裡要替換成我們自己的配置邏輯,我們要做拆包就需要保證這個 id 不能重複,但是這個 id 只是在打包時生成,如果我們單獨打業務包,基礎包,這個 id 的連續性就會丟失,所以對於 id 的處理,我們還是可以參考上述開源專案,每個包有十萬位間隔空間的劃分,基礎包從 0 開始自增,業務 A 從 1000000 開始自增,又或者通過每個模組自己的路徑或者 uuid 等去分配,來避免碰撞,但是字串會增大包的體積,這裡不推薦這種做法。

3.2 基於模組的拆包方案

下面我們來看一下metro-code-split 拆包工具,相對於基於文字的拆包方式,基於模組來拆包載入速度要更快一些。為什麼基於模組的拆包要比基於文字的拆包載入速度更快一些呢?這是因為,基於模組的拆包方式能夠獨立執行。

那為什麼基於模組的拆包方式,能夠獨立執行,而基於文字的拆包方式不能獨立執行呢?

我們先來看基於文字的拆包方式。假設我們採用的是多 Bundle 的基於文字的拆包方式。多個 Bundle 之間的公共程式碼部分是 “react”和“react-native”庫,這裡我用 console.log(“react”)、console.log(“react-native”) 來代替。多個 Bundle 之間不同的程式碼部分是業務程式碼,這裡用 console.log(“Foo”)來代替某個具體業務程式碼。

基於文字的拆包,我們採用的是 Google 開源的 diff-match-patch 演算法,它也提供了線上計算網站,它計算熱更新包的示意圖如下:

image.png

可以看到,在上面熱更新示意圖中,我們會把 Old Version 的字串檔案進行內建,這部分程式碼除了升級 React Native 版本之外不會輕易改動。而 New Version 的字串是本次熱更新的目的碼,也就是完整的 Bundle 檔案,但開發者並不需要下載完整的 Bundle 檔案,因為 Old Version 已經內建到 App 中了,我們只需要下發 Patch 熱更新包即可。客戶端接收到 Patch 熱更新包後,會和 Old Version 代表的內建包進行合併,最終載入的是經過合併的完整 Bundle包。

可以看到,基於文字的拆包與合包原理,Patch 熱更新包是一段記錄修改位置、修改內容的文字,而不是可獨立執行的程式碼,直接導致的結果是,只能等到下載完成後生成完整的 Bundle 檔案才能整體執行。這就是為什麼基於文字拆包方式不可獨立執行的原因。

但基於模組的拆包方式,內建包和熱更新包就可以分別獨立執行。同樣,還是以多 Bundle 模式的 Foo 業務熱更新為例,下面似乎基於模組拆包示意圖。

image.png

可以看到,基於模組拆包方案拆出來的熱更新包是可以獨立執行的。因此,使用模組拆包方案後,可以在客戶端先執行內建包,同時並行下載熱更新包,等熱更新包下載完成再接著執行熱更新包,當然也可以在應用啟動後就去下載,從而降低熱更新包的載入時長。

3.2 熱更新與拆包

經過前文的操作後,我們已經生成好的公共包、業務包、按需載入包,接下來就是如何實現熱更新並執行的問題。下面是一張拆包方案的熱更新示意圖。

image.png

因為我們採用的是模組拆包方案,雖然理論上每個包都是可以獨立執行的,但實際上模組和模組之間是有依賴關係的,整體上講,按需載入包會依賴業務包中的模組,業務包會依賴公共包中的模組。因此,需要先執行公共包、再執行業務包,最後執行按需載入包。

當然每個獨立的按需載入包之間也會有依賴關係,不過這些載入的依賴關係,metro-code-split 都已經幫你考慮到了,你直接用就行了。對於首頁是 Native 頁面,而其他頁面是 React Native 頁面的多 Bundle 混合應用而言,整體載入流程如下:

首先,在啟動 App 之後,找一個空閒時間,把 React Native “環境預建立” 好,然後把 “拆出來的公共包” 進行預載入。

然後,在使用者點選進入 React Native 頁面時,在相關跳轉協議中傳入 React Native 頁面的唯一識別符號或者 CDN 地址,下載業務包並進行頁面載入:

https://static001.geekbang.org/resource/rn/id999.buz.android.bundle

不過,對於一些複雜業務來說,頁面內容會比較多,把一些非首屏的程式碼放在業務包中會拖慢首屏的載入速度,因此更好的方案是,把這些程式碼放在按需載入包中進行載入。當使用者點選某個按鈕或者下拉時,會再觸發相關的按需載入邏輯。

此時,metro-code-split 會根據 import(‘xxx’) 中的引數路徑,找到對應的 CDN 地址,比如 Foo.js 模組對應的就是如下 CDN 地址:

https://static001.geekbang.org/resource/rn/03ad61906ed0e1ec92c2.bundle

然後,再根據該 CDN 地址請求按需載入包,並通過 new Function(code) 的方式執行下載回來的程式碼,把 Foo 元件載入到當前 JavaScript 的上下文中,並進行最終的渲染。以上方案適合首頁是 Native 頁面的混合應用,如果首頁也是 React Native 頁面怎麼辦呢?

1,首頁是 React Native 頁面,而且採用的是多 Bundle 策略

那麼,公共包依舊需要內建,並且首頁業務包也需要內建。此時,首頁業務包採用靜默更新策略,也就是當次下載、下次生效的策略。這樣每次啟動時首頁,首頁的業務包是從本地載入的,不走網路請求,首頁的啟動速度就會變快。其他頁面的業務包或按需載入包繼續採用,當次生效的動態下發形式進行更新。

當次生效的方式,大概多了 300ms~500ms 的 Bundle 下載時間,但帶來的好處是業務能夠隨時更新、Bug 能夠隨時修復,不用等到使用者下次進入頁面再生效。

2,首頁是 React Native 頁面,但採用的是單 Bundle 策略

那麼,公共包和業務包需要分別內建,其中公共包走發版更新流程,業務包走 CodePush 靜默更新流程。相對於純 CodePush 方案,通過拆包的方式,能夠節約 CodePush 更新的下載量體積。如果你還同時使用了按需載入包,那麼還能節約非首屏程式碼的執行時間。

如果遇到緊急 Bug,CodePush 也支援當次生效。但由於 CodePush 底層機制的原理,它不僅需要下載熱更新 Bundle,還需要重新載入整個 JavaScript 環境,耗時比較長,因此不建議你把它用作預設的更新方式。

四、總結

現在,使用開源拆包工具 metro-code-split 能夠很方便地幫你把整個 Bundle 包拆分成公共包、業務包和按需載入包。你只需要下載、配置和執行命令,就可以完成拆包操作了。

本地拆包只是熱更新流程中的一個環節,因此你需要配合你的熱更新流程一起使用。根據業務的不同,應用可大致分為三種形態,包括單 Bundle 的純 React Native 應用、多 Bundle 的純 React Native 以及多 Bundle 的混合應用,每種不同的形態的應用採用的熱更新方式和拆包策略都有所區別,你需要結合具體的場景進行分析。

雖然使用 metro-code-split 進行拆包很簡單,但要實現 metro-code-split 並不容易,在編譯時、執行時有大量的工作需要處理,你還得把所有模組的正向依賴、逆向依賴給理清楚,才能合理的進行拆包。

參考:metro-code-split 示例

相關文章