基于Metro的React Native拆包及Bundle文件異步加載實現

1. 概述

本文描述了一種基于Metro工具的構建差分包的方法,同時實現了在App中差分包的異步加載。通過實驗,對比同步的加載方式,異步加載方式會減少 20% ~ 25%(20 ~ 200 ms)的頁面加載時間消耗。

項目代碼:https://github.com/MarcusMa/react-native-async-load-bundle

效果圖

2. 相關背景

2.1 React Native 構建Bundle文件的目的

使用 ReactNative 開發的業務,無論是通過靜態內置還是動態下發的方式發布,都需要將業務 JavaScript 代碼打包成 Bundle文件。構建Bundle文件的主要有以下幾個目的:

  1. RN代碼使用 JSX 語法描述 UI 視圖,然而標準的 JS 引擎顯然不支持 JSX,所以需要將 JSX 語法轉換成標準的 JS 語法;
  2. RN代碼同時使用的 ES 6語言標準,目前 iOS、Android 上的 JS 引擎還不支持 ES 6,因此需要轉換;
  3. JS 業務代碼會依賴多個不同的模塊(JS 文件),RN 在打包時將所有依賴的模塊打包到一個 Bundle 文件中,較好地解決了這種復雜的依賴關系;
  4. JS 代碼的混淆。

2.2 Bundle文件結構及內容說明

React Native打包形成的Bundle文件的內容從上到下依次是:

  1. Polyfills:定義基本的JS環境(如:__d()函數、__r()函數、__DEV__ 變量等)
  2. Module定義:使用__d()函數定義所有用到的模塊,該函數為每個模塊賦予了一個模塊ID,模塊之間的依賴關系都是通過這個ID進行關聯的。
  3. Require調用:使用__r()函數引用根模塊。

業務不同的兩個Bundle文件,會在Polyfills部分及Module定義部分有大量重復,因為每個業務的JS文件中必定是與需要引用react及react-native兩個模塊的,該重復部分大約500K左右。

2.2.1 define()函數

__d()函數實際是define()函數,他的三個參數分別為:factory方法、module ID以及dependencyMap。

function define(factory, moduleId, dependencyMap) {
    if (moduleId in modules) {
        // that are already loaded    
        return;  
    }
    modules[moduleId] = { dependencyMap};
    // other code ....
};                

特別注意,它用modules變量對傳入的模塊進行了緩存控制。

2.2.2 require()函數

__r()函數實際是require(),這個方法首先判斷所要加載的模塊是否已經存在并初始化完成。若是,則直接返回模塊的exports,否則調用guardedLoadModule等方法對模塊進行初始化。

function require(moduleId) {
  const module = modules[moduleId];
  return module && module.isInitialized
    ? module.exports
    : guardedLoadModule(moduleIdReallyIsNumber, module);
}
function guardedLoadModule(moduleId, module) {
  return loadModuleImplementation(moduleId, module);
}
function loadModuleImplementation(moduleId, module) {
  module.isInitialized = true;
  const exports = (module.exports = {});
  var _module = module;
  const factory = _module.factory,
    dependencyMap = _module.dependencyMap;
  const moduleObject = { exports };
  factory(global, require, moduleObject, exports, dependencyMap);
  return (module.exports = moduleObject.exports);
}

特別注意它是使用module.isInitialized控制模塊的初始化。

2.3 Metro 工具

隨著React Native 版本迭代,官方已經逐步將bundle文件生成流程規范化,并為此設計了獨立的打包模塊 – Metro。Metro 通過輸入一個需要打包的JS文件及幾個配置參數,返回一個包含了所有依賴內容的JS文件。

Metro將打包的過程分為了3個依次執行的階段:

  1. 解析(Resolution):計算得到所有的依賴模塊,形成依賴樹,該過程是多線程并行執行。
  2. 轉義(Transformation):將模塊內容轉義為React Native可識別的格式,該過程是多線程并行執行。
  3. 序列化(Serialization):將所有的模塊合并到一個文件中輸出。
    Metro工具提供了配置功能,開發人員可以通過配置RN項目中的metro.config.js文件修改bundle文件的生成流程。

3. 基于Metro工具的新拆包方法

拆包主要是將一個RN業務完整Bundle文件(簡稱Business文件)與提前打包完成的基礎文件(簡稱:Common文件)進行比較,拆分出更小的業務包(簡稱:Diff文件)。目前比較易用的拆包方式是基于文本內容層面的差分再合并,即用google-diff-match-path或者BSDiff算法得到的差分包,這些差分包都是不可以直接運行的,需要經由“還原”的過程才能正常加載使用。此外,攜程提供自主研發的、基于JS代碼層面的拆包方案moles,但該方案主要針對React Native 0.44版本。

目前不使用基于JS代碼層面拆包方案,主要是因為React Native 0.55以前版本是不支持原生拆包,需要對React Native源碼進行改造。而Metro工具的提出為拆包提供了新的思路和方法。

新拆包方法主要關注的是Metro工具在“序列化”階段時調用的 createModuleIdFactory(path)方法和processModuleFilter(module)createModuleIdFactory(path)是傳入的模塊絕對路徑path,并為該模塊返回一個唯一的IdprocessModuleFilter(module)則可以實現對模塊進行過濾,使其不被寫入到最后的bundle文件中。

官方的createModuleIdFactory(path)方法是返回個數字。(如前所述,該數字在 require 方法中進行被調用,以此來實現模塊的導入和初始化)

"use strict";
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;
  };
}

官方的實現存在的問題是Id值從0開始分配,所以任意改動業務代碼可能引起模塊構建的順序變動,致使同一個模塊在兩次構建分配了有2個不同的Id值。

針對官方實現的問題,我們重新聲明一個createModuleIdFactory(path)方法,該方法使用當前模塊文件的路徑的哈希值作為分配模塊的Id的依據,并建立哈希值與模塊Id對應關系的本地存在文件,每次編譯Bundle文件前先讀取本地關系文件來初始化內部緩存,當需要分配Id時,先從內部緩存中查找,查找不到則新分配Id并存儲變化。

由上述步驟可以到達同一個模塊,無論編譯順序如何,返回的Id是同一個。關鍵代碼如下:

// 詳見 metro.config.base.js
// 省略其他代碼
function getFindKey(path) {
  let md5 = crypto.createHash("md5");
  md5.update(path);
  let findKey = md5.digest("hex");
  return findKey;
}
// 省略其他代碼
buildCreateModuleIdFactoryWithLocalStorage = function(buildConfig) {
    // 省略其他代碼
    moduleIdsJsonObj = getOrCreateModuleIdsJsonObj(moduleIdsMapFilePath);
    // 省略其他代碼
    return () => {
      return path => {
        let findKey = getFindKey(path);
        if (moduleIdsJsonObj[findKey] == null) {
          moduleIdsJsonObj[findKey] = {
            id: ++currentModuleId,
            type: buildConfig.type
          };
          saveModuleIdsJsonObj(moduleIdsMapFilePath, moduleIdsJsonObj);
        }
        let id = moduleIdsJsonObj[findKey].id;
        return id;
      };
    };
};

同時,為了能夠在processModuleFilter(module)方法中對模塊進行過濾,需要在構建Common文件時,標記某個模塊是否已包含在Common文件中。為此,我們在保存模塊id對應關系時,額外加上了type字段,該字段的值來源于構建腳本執行時傳入的參數。當構建Common文件時,該值為common,當構建Diff文件時,該值為diff

processModuleFilter(module)方法實現如下:

// 詳見 metro.config.base.js
// 省略其他代碼
buildProcessModuleFilter = function(buildConfig) {
  return moduleObj => {
    let path = moduleObj.path;
    if (!fs.existsSync(path)) {
      return true;
    }
    if (buildConfig.type == BUILD_TYPE_DIFF) {
      let findKey = getFindKey(path);
      let storeObj = moduleIdsJsonObj[findKey];
      if (storeObj != null && storeObj.type == BUILD_TYPE_COMMON) {
        return false;
      }
      return true;
    }
    return true;
  };
};
// ...
// 省略其他代碼

通過上述步驟構建出的Diff文件中,還保留了Pollyfills部分內容,需要進行刪除。刪除腳步位于./__async_load_shell__/removePollyfills.js中,代碼如下:

const fs = require('fs');
const readline = require('readline');

let argvs = process.argv.splice(2);
let filePath = argvs[0];

var fRead = fs.createReadStream(filePath);
var objReadline = readline.createInterface({
  input: fRead,
});
let diff = new Array();
objReadline.on('line', function(line) {
  if (line.startsWith('__d') || line.startsWith('__r')) {
    diff.push(line);
  }
});
objReadline.on('close', function() {
  let data = diff.join('\n');
  fs.writeFileSync(filePath, data);
});

使用方法如下:

node ./__async_load_shell__/removePolyfill.js  __async_load_output__/diff.ios.bundle 

通過以上步驟可以打包出Common文件和Diff文件。項目中的Business文件是基于React Native的模板工程,而Common源文件如下:

// 詳見common.js
require('react-native');
require('react');

為了進一步提高使用便捷性,我們在__async_load_shell__文件夾中定義便捷腳本,同時在package.json文件中定義快捷指令,具體如下:

//詳見package.json文件
{
 "scripts": {
    "build_android_common_bundle": "./__async_load_shell__/build_android_common_bundle.sh",
    "build_ios_common_bundle": "./__async_load_shell__/build_ios_common_bundle.sh",
    "build_android_index_bundle": "./__async_load_shell__/build_android_index_bundle.sh",
    "build_ios_index_bundle": "./__async_load_shell__/build_ios_index_bundle.sh",
    "build_android_index_diff_bundle": "./__async_load_shell__/build_android_index_diff_bundle.sh",
    "build_ios_index_diff_bundle": "./__async_load_shell__/build_ios_index_diff_bundle.sh",
    "copy_files_to_projects": "./__async_load_shell__/copy_files_to_projects.sh",
    // 省略其他代碼
  },
}

可以使用如下命令快捷進行Bundle文件構建:

npm run build_android_common_bundle  
npm run build_android_index_diff_bundle
npm run build_ios_common_bundle
npm run build_ios_index_diff_bundle

3. 異步加載實現

異步加載得利于基于Metro的拆包方法,使得App在進入真正的業務界面前可以先加載Common文件,再加載Diff 文件。

3.1 Android異步加載實現

在Android的實現中,我們構建了一個引導頁面AsyncLoadGuideActivity來初始化RN環境,并且在后臺加載Common文件, 這個頁面是作為RN容器頁面的父頁面存在的。 在正式的產品中,這個頁面通常使用來展示那些用RN構建的業務的入口。

關于異步加載的代碼均放置在com.marcus.rn.async包中。主要有如下幾個實現要點。

  1. 我們使用了 ReactNativeHost 對象指定了Common文件的加載路徑, 同時通過調用 createReactContextInBackground() 來初始化RN環境,并加載 Common文件。

  2. 為了能夠得知Common文件加載結束,我們使用了ReactInstanceManageraddReactInstanceEventListener()方法 添加了自定義監聽器,并且監聽 onReactContextInitialized() 回調。以onReactContextInitialized()回調觸發標志Common文件加載結束;

  3. 由于原生的ReactActivityDelegate 類和ReactActivity類的存在內部變量final定義限制等問題,我們重新定義了新的類AsyncLoadActivityDelegate類和AsyncLoadReactActivity類來適配異步加載的場景;

  4. 我們構建了單例類AsyncLoadManager 來統一管理AsyncLoadActivityDelegate 對象創建和分配

  5. RN頁面加載耗時將通過控制臺日志及Toast方式顯示, 這個值記錄了從啟動頁面的onCreate()到React Native的CONTENT_APPEARED事件觸發為止。

  6. 由于有全局變量污染的問題,這就要求我們在加載業務前必須進行清理RN運行環境。一種簡單的方法是拋出使用過的AsyncLoadActivityDelegate 對象,保證每次加載業務前的AsyncLoadActivityDelegate對象都是新創建的并且完成了Common文件的加載, 請參考 AsyncLoadManager類中prepareReactNativeEnv() 方法。

3.2 iOS異步加載實現

  1. 我們需要暴露 RCTBridge類中executeSourceCode 方法,這樣才能加載自定義的JavaScript代碼,新建文件RCTBridge.h:
// RCTBridge.h
#import <Foundation/Foundation.h>
@interface RCTBridge (RnLoadJS)
 - (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;
@end
  1. 通過使用RCTBridgeDelegatesourceURLForBridge 方法指定了Common文件位置,并通過調用RCTBridge的初始化方法[[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]初始化React Native 的運行環境和加載Common文件;
  2. 我們構建了單例類MMAsyncLoadManager 來統一管理RCTBridge 對象創建和分配;
  3. RN頁面加載耗時將通過控制臺日志及Toast方式顯示, 這個值記錄了從啟動頁面的viewDidLoad到React Native的RCTContentDidAppearNotification通知觸發為止;
  4. 由于有全局變量污染的問題,這就要求我們在加載業務前必須進行清理RN運行環境。一種簡單的方法是拋出使用過的RCTBridge 對象,保證每次加載業務前的RCTBridge對象都是新創建的并且完成了Common文件的加載, 請參考 MMAsyncLoadManager類中prepareReactNativeEnv() 方法。

4. 實驗數據

4.1 Bundle文件比較

Android File Size Size After gzip
common.android.bundle 637.0 K 175K
index.android.bundle (Original) 645.0 K 177K
diff.android.bundle (Using BSDiff) 3.9 K 3.9 K
diff.android.bundle (Using google-diff-match-patch) 11.0 K 3.0 K
diff.android.bundle (Using Metro) 8.3 K 2.5 K
iOS File Size Size After gzip
common.ios.bundle 629.0 K 173K
index.ios.bundle (Original) 637.0 K 176K
diff.ios.bundle (Using BSDiff) 3.9 K 3.9 K
diff.ios.bundle (Using google-diff-match-patch) 11.0 K 3.0 K
diff.ios.bundle (Using Metro) 8.3 K 2.5 K

可以在 這里找到google-diff-match-patchBSDiff 的實現代碼。

4.2 RN頁面加載時間比較

加載方式\設備型號 Redmi 3 Huawei P20 iPhone 6s iPhone XS MAX
同步加載 868.2 ms 337.8 ms 405.3 ms 109.2 ms
異步加載 643.4 ms 253.2 ms 300.2 ms 88.3 ms
-25.89% -25.04% -25.88% -18.68%
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373

推薦閱讀更多精彩內容