基于Metro的React Native拆包及Bundle文件异

作者: MarcusMa | 来源:发表于2020-03-27 15:25 被阅读0次

    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%

    相关文章

      网友评论

        本文标题:基于Metro的React Native拆包及Bundle文件异

        本文链接:https://www.haomeiwen.com/subject/fbqqjhtx.html