概览
背景:在 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.ASSET
和ScriptType.FILE
,通过当前手机是否存在比内置包更高版本的RN包进行判断。
业务包路径则通过页面传输字段PageName来判断加载哪个业务包,如:HomePage/Me
,则就是加载HomePage.bundle
int index = pageName.indexOf("/");
if (index != -1){
return pageName.substring(0, index)+".bundle";
}