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文件的主要有以下幾個目的:
- RN代碼使用 JSX 語法描述 UI 視圖,然而標準的 JS 引擎顯然不支持 JSX,所以需要將 JSX 語法轉換成標準的 JS 語法;
- RN代碼同時使用的 ES 6語言標準,目前 iOS、Android 上的 JS 引擎還不支持 ES 6,因此需要轉換;
- JS 業務代碼會依賴多個不同的模塊(JS 文件),RN 在打包時將所有依賴的模塊打包到一個 Bundle 文件中,較好地解決了這種復雜的依賴關系;
- JS 代碼的混淆。
2.2 Bundle文件結構及內容說明
React Native打包形成的Bundle文件的內容從上到下依次是:
- Polyfills:定義基本的JS環境(如:
__d()
函數、__r()
函數、__DEV__
變量等) - Module定義:使用
__d()
函數定義所有用到的模塊,該函數為每個模塊賦予了一個模塊ID,模塊之間的依賴關系都是通過這個ID進行關聯的。 - 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個依次執行的階段:
- 解析(Resolution):計算得到所有的依賴模塊,形成依賴樹,該過程是多線程并行執行。
- 轉義(Transformation):將模塊內容轉義為React Native可識別的格式,該過程是多線程并行執行。
-
序列化(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
,并為該模塊返回一個唯一的Id
。processModuleFilter(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
包中。主要有如下幾個實現要點。
我們使用了
ReactNativeHost
對象指定了Common文件的加載路徑, 同時通過調用createReactContextInBackground()
來初始化RN環境,并加載 Common文件。為了能夠得知Common文件加載結束,我們使用了
ReactInstanceManager
的addReactInstanceEventListener()
方法 添加了自定義監聽器,并且監聽onReactContextInitialized()
回調。以onReactContextInitialized()
回調觸發標志Common文件加載結束;由于原生的
ReactActivityDelegate
類和ReactActivity
類的存在內部變量final
定義限制等問題,我們重新定義了新的類AsyncLoadActivityDelegate
類和AsyncLoadReactActivity
類來適配異步加載的場景;我們構建了單例類
AsyncLoadManager
來統一管理AsyncLoadActivityDelegate
對象創建和分配RN頁面加載耗時將通過控制臺日志及Toast方式顯示, 這個值記錄了從啟動頁面的
onCreate()
到React Native的CONTENT_APPEARED
事件觸發為止。由于有全局變量污染的問題,這就要求我們在加載業務前必須進行清理RN運行環境。一種簡單的方法是拋出使用過的
AsyncLoadActivityDelegate
對象,保證每次加載業務前的AsyncLoadActivityDelegate
對象都是新創建的并且完成了Common文件的加載, 請參考AsyncLoadManager
類中prepareReactNativeEnv()
方法。
3.2 iOS異步加載實現
- 我們需要暴露
RCTBridge
類中executeSourceCode
方法,這樣才能加載自定義的JavaScript代碼,新建文件RCTBridge.h
:
// RCTBridge.h
#import <Foundation/Foundation.h>
@interface RCTBridge (RnLoadJS)
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;
@end
- 通過使用
RCTBridgeDelegate
的sourceURLForBridge
方法指定了Common文件位置,并通過調用RCTBridge
的初始化方法[[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]
初始化React Native 的運行環境和加載Common文件; - 我們構建了單例類
MMAsyncLoadManager
來統一管理RCTBridge
對象創建和分配; -
RN頁面加載耗時將通過控制臺日志及Toast方式顯示, 這個值記錄了從啟動頁面的
viewDidLoad
到React Native的RCTContentDidAppearNotification
通知觸發為止; - 由于有全局變量污染的問題,這就要求我們在加載業務前必須進行清理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-patch
和BSDiff
的實現代碼。
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% |