likes
comments
collection
share

React Native拆包原理与实现

作者站长头像
站长
· 阅读数 45

概览

背景:在 React Native 中,我们打包生成的包只有一个jsbundle,里面包含了我们的业务代码、RN 源码及依赖的第三方库。通常为了更好的性能,我们需要将这个jsbundle文件进行拆分,得到一个基础包和多个业务包。

问题:尽管拆包可以带来诸多好处,如减少页面首次加载时间,降低内存资源消耗,减少更新内容包的大小等,但如何进行有效的拆包呢?

策略:我们采用基于 Metro 进行拆包的方法,Metro 是 React Native 官方提供的打包工具,我们基于 Metro 二次开发,实现了 jsbundle 拆分为一个基础包和多个业务包。拆包步骤如下:

  • Metro提供了两个配置项createModuleIdFactory和processModuleFilter,前者用于生成require语句的模块ID,后者用于过滤掉一些特定的模块。
  • 公司基于这两个配置项进行了拆包的实现,首先配置createModuleIdFactory让它每次打包生成的module都使用固定的id,然后配置processModuleFilter过滤基础包,打出对应业务包。
  • 为了避免基础包内的第三方库重复打入,公司在生成基础包时,把所有依赖的模块name放到一个数组并写入到一个本地文件中,这个文件保存了基础包中的依赖信息。在打业务包时,读取这个文件的内容,就可以识别基础包已存在的依赖库,不再重复打入。
  • 在打包过程中,公司将基础包中包含的RN源码、第三方依赖库、内部公共组件等,通过import方式引入,然后使用react-native的bundle命令执行打包。
  • 在加载过程中,公司让APP在启动时先加载基础包,然后再按需加载业务包。同时,公司在iOS和Android上分别实现了基础包和业务包的加载方式。

效果:通过这种方式,我们可以在 APP 启动时提前加载基础包,在需要进入 RN 页面时,再动态加载该页面所在的业务模块文件,实现按需加载。在热更新时只更新有变化的业务包,再配合 bsdiff 差分算法,大大减少更新内容包的大小。拆包后能更好地支持动态下发业务包,动态加载,从而让我们更灵活地部署、上线。

拆包方案简介

在 React Native 中,我们打包生成的包只有一个jsbundle,里面包含了我们的业务代码、RN 源码及依赖的第三方库,通常为了更好的性能,我们会拆分这个jsbundle文件,得到一个基础包和多个业务包。

基础包:将重复的React Native代码与第三方依赖库打包成一个文件。

业务包:按照应用内的不同业务单元,拆分出一个或多个包。

拆包后,让基础包在 APP 启动时提前加载到内存中,在需要进入 RN 页面时,再动态加载该页面所在的业务模块文件,按需加载。

拆包给我们带来了很多好处,如下:

  • 提前加载 js 框架,这样在进入RN页面时,只需要加载业务js代码,从而减少RN页面首次加载时间;

  • 打开哪个页面加载哪个业务包,避免一次性加载全部js代码,降低内存资源消耗;

  • 在热更新时只更新有变化的业务包,再配合 bsdiff 差分算法,大大减少更新内容包的大小;

  • 拆包后能更好地支持动态下发业务包,动态加载,从而让我们更灵活地部署、上线。

现有的几种拆包方案:

1,diff patch

首先生成基础包,只引用RN源码和第三方依赖库,然后现生成完成的jsbundle,通过diff比对基础包和完整的jsbundle,得出业务包。

优点:简单

缺点:只能拆分包,对性能没有提升,反而增加了合包带来的时间消耗

2,CRN

携程最近开源的拆包方案,包含了拆包、框架代码预加载、两端一套产物、懒require等。

优点:性能好,两端一套产物

缺点:成本高,对RN源码、打包工具改动较大,难升级、难维护

3,Metro

官方出的打包工具,从 0.57 开始,已经支持拆包了。

优点:稳定可靠,无需改动RN源码

缺点:性能没有CRN好

我们的业务规模还不大,哪个方案下页面加载速度和内存问题都不会很严重,出于成本和稳定性考虑,最终选择了 Metro 方案。

下面介绍如何基于 Metro 进行拆包的原理和实现过程。

拆包

Metro 是 React Native 官方提供的打包工具,它将我们的业务代码及依赖的第三方库打包生成一个jsbundle文件。我们基于 Metro 二次开发,实现了 jsbundle 拆分为一个基础包和多个业务包。

React Native Metro 提供了一个打包配置:facebook.github.io/metro/docs/…

其中有两个配置项:


createModuleIdFactory

Type: () => (path: string) => number

Used to generate the module id for require statements.

  
processModuleFilter

Type: (module: Array<Module>) => boolean


A filter function to discard specific modules from the output.

createModuleIdFactory:用于生成 require 语句的模块ID,配置 createModuleIdFactory 让它每次打包生成的 module 都使用固定的id。它的返回值是一个函数,参数 path 是各个 module 的绝对路径,返回的是打包后的 module 的 id。

processModuleFilter:按照给定的规则,过滤掉一些特定的 module,配置 processModuleFilter 过滤基础包,打出对应业务包。它返回一个 boolean 类型,输入参数为 module 信息,如果返回 false,就过滤掉,不打入 bundle。


function createModuleIdFactory() {

    const projectRootPath = __dirname;//获取当前目录,__dirname是nodejs提供的变量

    return path => {

        let name = '';

        if (path.indexOf('node_modules' + pathSep + 'react-native' + pathSep + 'Libraries' + pathSep) > 0) {

          name = path.substr(path.lastIndexOf(pathSep) + 1);

        } else if (path.indexOf(projectRootPath) == 0) {

          name = path.substr(projectRootPath.length + 1);

        }

        name = name.replace('.js', '');

        name = name.replace('.png', '');

        let regExp = pathSep == '\\' ? new RegExp('\\\\', "gm") : new RegExp(pathSep, "gm");

        name = name.replace(regExp, '_');//把path中的/换成下划线

        return name;

    };

}

依据传入的模块路径 path,解析出一个路径名称,并作为 id 返回。


function processModuleFilter(module) {

  //过滤掉path为__prelude__的一些模块(基础包内已有)

  if (module['path'].indexOf('__prelude__') >= 0) {

    return false;

  }

  //过滤掉node_modules内的模块(基础包内已有)

  if (module['path'].indexOf(pathSep + 'node_modules' + pathSep) > 0) {

    /*

      但输出类型为js/script/virtual的模块不能过滤,一般此类型的文件为核心文件,

      如InitializeCore.js。每次加载bundle文件时都需要用到。

    */

    if ('js' + pathSep + 'script' + pathSep + 'virtual' == module['output'][0]['type']) {

      return true;

    }

    return false;

  }

  //其他就是应用代码

  return true;

}

在上面的代码,我们简单地将基础包内的模块(node_modules)过滤掉。

以上完成了对 jsbundle 的拆分,但仍不完善,因为它只是简单的过滤了 RN 源码,基础包内的引入的第三方库并没有过滤,我们继续优化。


const platfromNameArray = [];

  


function createModuleIdFactory() {

    const projectRootPath = __dirname;//获取当前目录,__dirname是nodejs提供的变量

    return path => {

        ......

        let name = ...

        const platformMapDir = __dirname+pathSep+moduleMapDir;

        if(!fs.existsSync(platformMapDir)){

            fs.mkdirSync(platformMapDir);

        }

        const platformMapPath = platformMapDir+pathSep+platfromMapName;

        fs.writeFileSync(platformMapPath,JSON.stringify(platfromNameArray));

  


        return name;

    };

}


const plaformModules = require('./multibundler/platformMapping.json');

function postProcessModulesFilter(module) {

    ......

        const name = getModuleId(projectRootPath,path);

        if (plaformModules.indexOf(name) >= 0) {//这个模块在基础包已打好,过滤

            return false;

        }

    ......

}

在生成基础包时,把所有依赖的模块 name 放到一个数组并写入到一个本地文件中,它保存了基础包中的依赖信息,这样在打业务包里,读取这个文件的内容,就可以识别基础包已存在的依赖库,不再重复打入。

当生成业务包时,从此文件中读取并进行判断,如果在该文件中已存在,则不打入业务包。

至此,拆包才算真正完成。

打包

通常基础包中包含RN源码、第三方依赖库、内部公共组件等,通过 import 方式引入进来,common.js代码如下:


import 'react';

import 'react-native';

import 'lodash';

import 'moment'

import 'prop-types'

import 'react-native-keyboard-aware-scroll-view'

import 'react-native-popup-menu'

import './src/components';

import './nav'

使用 react-native 的 bundle 命令执行打包,入口文件为common.js,并加上 --config <基础包配置文件common.config.js>,最终命令如下:


node ./node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file common_entry.js --bundle-output ./bundle/iOS/common.ios.jsbundle --assets-dest ./bundle/iOS/ --config ./common.config.js

同样,业务包入口文件 import 所需要的业务页面及相关文件,并使用业务包的配置文件,最终命令如下:


node ./node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file index_patient_manager.js --bundle-output ./bundle/iOS/PatientManager.ios.jsbundle --assets-dest ./bundle/iOS/ --config ./business.config.js

node ./node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file index_chronic_disease.js --bundle-output ./bundle/iOS/ChronicDisease.ios.jsbundle --assets-dest ./bundle/iOS/ --config ./business.config.js

node ./node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file index_personal_center.js --bundle-output ./bundle/iOS/PersonalCenter.ios.jsbundle --assets-dest ./bundle/iOS/ --config ./business.config.js

node ./node_modules/react-native/local-cli/cli.js bundle --platform ios --dev false --entry-file index_home_page.js --bundle-output ./bundle/iOS/HomePage.ios.jsbundle --assets-dest ./bundle/iOS/ --config ./business.config.js

基础包、业务包加载

在 RN 中,加载 js 代码、绑定视图的逻辑可以分开执行,因此我们可以轻松地实现加载基础包、绑定视图的分步执行。 

在Debug模式下,并不会进行拆包打包,所以无按需加载业务包的过程,基础代码和业务代码都存储于index.bundle中。

iOS

基础包预加载

APP 启动时,先加载基础包,不展示视图。


//直接使用基础包初始化js框架

  NSURL *jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"common.ios" withExtension:@"jsbundle"];

  bridge = [[RCTBridge alloc] initWithBundleURL:jsCodeLocation

                                 moduleProvider:nil

                                  launchOptions:launchOptions];

业务包加载

暴露RCTBridge的executeSourceCode方法


#import "RCTBridge.h"

  


@interface RCTBridge (RNLoadJS)

- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;

  


@end

加载业务包


NSURL *jsCodeLocationBuz = [[NSBundle mainBundle] URLForResource:bundleName withExtension:@"jsbundle"];

NSError *error = nil;

NSData *sourceBus = [NSData dataWithContentsOfFile:jsCodeLocationBuz.path

                                             options:NSDataReadingMappedIfSafe

                                               error:&error];

[bridge.batchedBridge executeSourceCode:sourceBus sync:NO];

最后绑定视图


RCTRootView* view = [[RCTRootView alloc] initWithBridge:bridge moduleName:moduleName initialProperties:nil];//bridge和module传入

Android

基础包预加载以及HomePage业务包预加载

private void preLoadBundle(){

  ReactInstanceManager reactInstanceManager = getReactInstanceManager();

  //这里会先加载基础包index.bundle

  if (reactInstanceManager != null && !reactInstanceManager.hasStartedCreatingInitialContext()) {

    getReactInstanceManager().addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() {

      @Override

      public void onReactContextInitialized(ReactContext context) {

        //加载完成预加载HomePage.bundle

        ScriptLoadUtil.loadScript(getReactInstanceManager(),  BridgeUtil.getScriptPathType("Me"), BridgeUtil.getScriptPath("Me"));

        if (getReactInstanceManager() != null) {

          getReactInstanceManager().removeReactInstanceEventListener(this);

        }

      }

    });

    reactInstanceManager.createReactContextInBackground();

  }

}

业务包加载

通过传入业务包的类型和路径加载


public static void loadScript(ReactInstanceManager instanceManager, RNUpdateConfig.ScriptType pathType, String scriptPath){

  // 当设置成debug模式时,所有需要的业务代码已经都加载好了

  if (DevKitConfig.DEBUG && ReactUtil.isFromServer(instanceManager)){

    return;

  }

  if (instanceManager != null && instanceManager.getCurrentReactContext() != null){

    CatalystInstance instance = instanceManager.getCurrentReactContext().getCatalystInstance();

    if(pathType== RNUpdateConfig.ScriptType.ASSET) {

      ScriptLoadUtil.loadScriptFromAsset(WYCoreUtils.getApp(), instance, scriptPath,false);

    }else {

      File scriptFile = new File(scriptPath);

      scriptPath = scriptFile.getAbsolutePath();

      ScriptLoadUtil.loadScriptFromFile(scriptPath, instance, scriptPath,false);

    }

  }

}

业务包类型分为ScriptType.ASSETScriptType.FILE,通过当前手机是否存在比内置包更高版本的RN包进行判断。

业务包路径则通过页面传输字段PageName来判断加载哪个业务包,如:HomePage/Me,则就是加载HomePage.bundle


int index = pageName.indexOf("/");

if (index != -1){

  return pageName.substring(0, index)+".bundle";

}

转载自:https://juejin.cn/post/7255189526775955511
评论
请登录