基于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阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容