React Native 中實現動態匯入

雲音樂技術團隊發表於2022-07-07

圖片來源:https://unsplash.com/photos/9...

本文作者:ssskkk

背景

隨著業務的發展,每一個 React Native 應用的程式碼數量都在不斷增加,bundle 體積不斷膨脹,對應用效能的負面影響愈發明顯。雖然我們可以通過 React Native 官方工具 Metro 進行拆包處理,拆分為一個基礎包和一個業務包進行一定程度上的優化,但對日益增長的業務程式碼也無能為力,我們迫切地需要一套方案來減小我們 React Native 應用的體積。

多業務包

第一個想到的就是拆分多業務包,既然拆分為一個業務包不夠,那我多拆為幾個業務包不就可以了。當一個 React Native 應用拆分為多個業務包之後其實就相當於拆分為多個應用了,只不過程式碼在同一倉庫裡。這雖然可以解決單個應用不斷膨脹的問題,但是有不少侷限性。接下來一一分析:

  • 連結替換,不同的應用需要不同的地址,替換成本較高。
  • 頁面之間通訊,之前是個單頁應用,不同頁面之間可以直接通訊;拆分之後是不同應用相互通訊需要藉助客戶端橋接實現。
  • 效能損耗,開啟每個拆分的業務包都需要單獨起一個 React Native 容器,容器初始化、維持都需要消耗記憶體、佔用CPU。
  • 粒度不夠,最小的維度也是頁面,無法繼續對頁面中的元件進行拆分。
  • 重複打包,部分在不同頁面之間共享的工具庫,每個業務包都會包含。
  • 打包效率,每一個業務包的打包過程,都要經過一遍完整的 Metro 打包過程,拆分多個業務包打包時間成倍增加。

動態匯入

作為一個前端想到的另一方案自然就是動態匯入(Dynamic import)了,基於其動態特性對於多業務包的眾多缺點,此方案都可避免。此外擁有了動態匯入我們就可以實現頁面按需載入,元件懶載入等等能力。但是 Metro 官方並不支援動態匯入,因此需要對 Metro 進行深度定製,這也是本文即將介紹的在 React Native 中實現動態匯入。

Metro 打包原理

在介紹具體方案之前我們先看下 Metro 的打包機制及其構建產物。

打包過程

如下圖所示Metro打包會經過三個階段,分別是 Resolution、Transformation、Serialization。
image

Resolution 的作用是從入口開始構建依賴圖;Transformation 是和 Resolution 階段同時執行的,其目的是將所有 module(一個模組就是一個 module ) 轉換為目標平臺可識別語言,這裡面既有高階 JavaCript 語法的轉換(依賴 BaBel),也有對特定平臺,比如安卓的特殊 polyfills。這兩個階段主要是生產中間產物 IR 為最後一階段所消費。

Serialization 則是將所有 module 組合起來生成 bundle,這裡需要特別注意 Metro API 文件中 Serializer Options 中的兩個配置:

  • 簽名為 createModuleIdFactory, type 為 () => (path: string) => number。 這個函式為每個 module 生成一個唯一的 moduleId,預設情況下是自增的數字。所有的依賴關係都依仗此 moduleId。
  • 簽名為 processModuleFilter, type 為 (module: Array<Module>) => boolean。這個函式用來過濾模組,決定是否打入 bundle。

bundle 分析

一個 React Native 典型的 bundle 從上到下可以分為三個部分:

  • 第一部分為 polyfills,主要是一些全域性變數如 __DEV__;以及通過 IIFE 宣告的一些重要全域性函式,如: __d__r 等;
  • 第二部分是各個 module 的定義,以 __d 開頭,業務程式碼全部在這一塊;
  • 第三部分是應用的初始化 __r(react-native/Libraries/Core/InitializeCore.js moduleId)__r(${入口 moduleId})
    我們看下具體函式的分析

    __d函式

    function define(factory, moduleId, dependencyMap) {
      const mod = {
          dependencyMap,
          factory,
          hasError: false,
          importedAll: EMPTY,
          importedDefault: EMPTY,
          isInitialized: false,
          publicModule: {
              exports: {}
          }
      };
      modules[moduleId] = mod;
    }

    __d 其實就是 define 函式,可以看到其實現很簡單,做的就是宣告一個 mode,同時 moduleIdmode 做了一層對映,這樣通過 moduleId 就可以拿到 module 實現。我們看下 __d 如何使用:

__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
    var _reactNative = _$$_REQUIRE(_dependencyMap[0], "react-native");

    var _reactNavigation = _$$_REQUIRE(_dependencyMap[1], "react-navigation");

    var _reactNavigationStack = _$$_REQUIRE(_dependencyMap[2], "react-navigation-stack");

    var _routes = _$$_REQUIRE(_dependencyMap[3], "./src/routes");

    var _appJson = _$$_REQUIRE(_dependencyMap[4], "./appJson.json");

    var AppNavigator = (0, _reactNavigationStack.createStackNavigator)(_routes.RouteConfig, (0, _routes.InitConfig)());
    var AppContiner = (0, _reactNavigation.createAppContainer)(AppNavigator);

    _reactNative.AppRegistry.registerComponent(_appJson.name, function () {
        return AppContiner;
    });
}, 0, [1, 552, 636, 664, 698], "index.android.js");

這是 __d 的唯一用處,定義一個 module。這裡解釋下入參,第一個是個函式,就是 module 的工廠函式,所有的業務邏輯都在這裡面,其是在 __r 之後呼叫的;第二個是 moduleId,模組的唯一標識;第三部分是其依賴的模組的 moduleId;第四個是此模組的檔名稱。

__r函式

function metroRequire(moduleId) {

    ...

    const moduleIdReallyIsNumber = moduleId;
    const module = modules[moduleIdReallyIsNumber];
    return module && module.isInitialized
        ? module.publicModule.exports
        : guardedLoadModule(moduleIdReallyIsNumber, module);
}

function guardedLoadModule(moduleId, module) {

    ...
    
    return loadModuleImplementation(moduleId, module);
}

function loadModuleImplementation(moduleId, module) {

    ...

    const moduleObject = module.publicModule;
    moduleObject.id = moduleId;
    factory(
        global,
        metroRequire,
        metroImportDefault,
        metroImportAll,
        moduleObject,
        moduleObject.exports,
        dependencyMap
    ); 
    return moduleObject.exports;

    ...
}

__r 其實就是 require 函式。如上精簡後的程式碼所示,require 方法首先判斷所要載入的模組是否已經存在並初始化完成,若是則直接返回模組,否則呼叫 guardedLoadModule 方法,最終呼叫的是 loadModuleImplementation 方法。loadModuleImplementation 方法獲得模組定義時傳入的 factory 方法並呼叫,最後返回。

方案設計

基於以上對 Metro 工作原理及其產物 bundle 的分析,我們可以大致得出這樣一個結論:React Native 啟動時,JS 測(即 bundle)會先初始化一些變數,接著通過 IIFE 宣告核心方法 definerequire;接著通過 define 方法定義所有的模組,各個模組的依賴關係通moduleId 維繫,維繫的紐帶就是 require;最後通過 require 應用的註冊方法實現啟動。

實現動態匯入自然需要將目前的 bundle 進行重新拆分和組合,整個方案的關鍵點在於:分和合,分就是 bundle 如何拆分,什麼樣的 module 需要拆分出去,什麼時候進行拆分,拆分之後的 bundle 儲存在哪裡(涉及到後續如何獲取);合就是拆出去的 bundle 如何獲取,並在獲取之後仍在正確的上下文內執行。

前面有說過 Metro 工作的三個階段,其中之一就是 Resolution,這一階段的主要任務是從入口開始構建整個應用依賴圖,這裡為了方便示意以樹來代替。
image

識別入口

如上所示就是一個依賴樹,正常情況下會打出一個 bundle,包含模組 A、B、C、D、E、F、G。現在我想對模組 B 和 F 做動態匯入。怎麼做呢第一步當然是標識,既然叫動態匯入自然而然的想到了 JavaScript 語法上的動態匯入。
只需要將 import A from '.A' 改成 const A = import('A') 即可,這就需要引入 Babel 外掛()了,事實上官方 Metro 相關配置包 metro-config 已經整合了此外掛。官方做的不僅僅於此,在 Transformation 階段還對採用動態匯入的 module 增加了唯一標識 Async = true

此外在最終產物 bundle 上 Metro 提供了一個名叫 AsyncRequire.js 的檔案模版來做動態匯入的語法的 polyfill,具體實現如下

const dynamicRequire = require;

module.exports = function(moduleID) {
    return Promise.resolve().then(() => dynamicRequire.importAll(moduleID));
};

總結一下 Metro 預設會如何處理動態匯入:在 Transformation 通過 Babel 外掛處理動態匯入語法,並在中間產物上增加標識 Async,在 Serialization 階段用 Asyncrequire.js 作為模板替換動態匯入的語法,即

const A = import(A);

變為

const A = function(moduleID) {
    return Promise.resolve().then(() => dynamicRequire.importAll(moduleID));
};

Asyncrequire.js 不僅關乎我們如何拆分,還和我們最後的合息息相關,留待後續再談。

樹拆分

通過上文我們知道構建過程中會生成一顆依賴樹,並對其中使用動態的匯入的模組做了標識,接下來就是樹如何進行拆分了。對於樹的通用處理辦法就是 DFS,通過對上圖依賴樹做 DFS 分析之後可以得到如下做了拆分的樹,包含一顆主樹和兩顆非同步樹。對於每棵樹的依賴進行收集即可得到如下三組 module 集合:A、E、C;B、D、E、G;F、G。

image

當然在實際場景中,各個模組的依賴遠比這個複雜,甚至存在迴圈依賴的情況,在做 DFS 的過程中需要遵循兩個原則:

  • 已經在處理過的 module,後續遇到直接退出迴圈
  • 各個非同步樹依賴的非主樹 module 都需要包含進來

    bundle 生成

    通過這三組 module 集合即可得到三個bundle(我們將主樹生成的 bundle 稱為主 bundle;非同步樹生成的稱為非同步 bundle)。至於如何生成,直接藉助前文提到的 Metro 中 processBasicModuleFilter 方法即可。Metro 原本在一次構建過程中,只會經過一次 Serialization 階段生成一個 bundle。現在我們需要對每一組 module 都進行一次 bundle 生成。

這裡需要注意幾個問題:

  • 去重,一種是已經打入主 bundle 的 module 非同步 bundle 不需要打入;一種是同時存在於不同非同步樹內的 module,對於這種 module,我們可以將其標記為動態匯入單獨打包,見下圖
    image
  • 生成順序,需要先生成非同步 bundle,再生成主 bundle。因為需要將非同步 bundle 的資訊(比如檔名稱、地址)與 moduleId 做對映填入主 bundle,這樣在真正需要的時候可以通過 moduleId 的對映拿到非同步 bundle 的地址資訊。
  • 快取控制,為了保證每個非同步 bundle 在能夠享受快取機制的同時能夠及時更新,需要對非同步 bundle 做 content hash 新增到檔名上
  • 儲存,非同步 bundle 如何儲存,是和主 bundle 一起,還是單獨儲存,需要時再去獲取呢。這個需要具體分析:對於採用了bundle 預載入的可以將非同步 bundle 和主 bundle 放到一起,需要時直接從本地拿即可(所謂預載入就是在客戶端啟動時就已經將所有 bundle 下載下來了,在使用者開啟 React Native 頁面時無需再去下載 bundle)。對於大部分沒有采用預載入技術的則分開儲存更合適。

至此我們已經獲得了主 bundle 和非同步 bundle,大致結構如下:

/* 主 bundle */

// moduleId 與 路徑對映
var REMOTE_SOURCE_MAP = {${id}: ${path}, ... }

// IIFE __r 之類定義
(function (global) {
  "use strict";
  global.__r = metroRequire;
  global.__d = define;
  global.__c = clear;
  global.__registerSegment = registerSegment;
  ...
})(typeof global !== 'undefined' ? global : typeof window !== 'undefined' ? window : this);

//  業務模組
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
  var _reactNative = _$$_REQUIRE(_dependencyMap[0], "react-native");
  var _asyncModule = _$$_REQUIRE(_dependencyMap[4], "metro/src/lib/bundle-modules/asyncRequire")(_dependencyMap[5], "./asyncModule")
  ...
},0,[1,550,590,673,701,855],"index.ios.js");

...

// 應用啟動
__r(91);
__r(0);
/* 非同步 bundle */

// 業務模組
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
  var _reactNative = _$$_REQUIRE(_dependencyMap[0], "react-native");
  ...
},855,[956, 1126],"asyncModule.js");

大部分工作其實在這一階段已經做完了,接下來就是如何合了,前面有提到過動態匯入的語法在生成的 bundle 中會被 AsyncRequire.js 中的模板所替代。仔細研究下其程式碼發現其是用 Promise 包裹了一層 require(moduleId) 來實現。
現在我們直接 require(moduleId) 必然是拿不到真正的 module 實現了,因為非同步 bundle 還沒有獲取到,module 還沒有定義。但可以對 AsyncRequire.js 做如下改造

const dynamicRequire = require;
module.exports = function (moduleID) {
    return fetch(REMOTE_SOURCE_MAP[moduleID]).then(res => {  // 行1
        new Function(res)();                                 // 行2
        return dynamicRequire.importAll(moduleID)            // 行3
    });
};

接下來一行行進行分析

  • 行1將之前 mock 的 Promise 替換為真正的 Promise 請求,先去獲取 bundle 資源,REMOTE_SOURCE_MAP 是在生成階段寫入主 bundle 的 moduleId 與非同步 bundle 資源地址的對映。fetch 根據非同步 bundle 的儲存方式的不同選擇不同的方式獲取真正的程式碼資源;
  • 行2通過 Function 方法執行獲取到的程式碼,即是模組的宣告,這樣最後返回 module 的時候就已經是定義過的了;
  • 行3 返回真正的模組實現。
    這樣我們就實現了,非同步 bundle 的獲取、執行就都在 AsyncRequire.js 內完成了。

總結

至此我們就完成了 React Native 動態匯入的改造。相對於多業務包,因為其動態特性使得業務方使用的時候所有修改都在同一個 React Native 應用內部閉環完成,外部無感知,多業務包的眾多缺陷也就不存在了。與此同時構建時會充分利用第一次的生產的 IR,這樣每一個 bundle 不需要再單獨走 Metro 的完整構建流程。

當然有一點是必須需要考慮的,那就是我們對 Metro 進行改造之後,對於後續的升級是否有影響,導致只能鎖定 React Native 和 Metro 版本。這個其實完全不用擔心,從前面的分析可以知道,我們對於整個流程的改造可以分為兩部分:構建時、執行時。在構建時我們確實新增了不少能力,比如新的分組演算法、程式碼生成;但是執行時則是完全基於現有版本能力的增強。這就使得動態匯入的執行時無相容性問題,即使升級到新版本依然不會報錯,只不過再我們再次改造構建時之前失去了動態匯入的能力。

最後真正在生產環境上使用還有一些工程上的改造,比如:構建平臺適配、提供快速接入元件等等限於篇幅就不在此詳述了。

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

相關文章