react-native 拆包/分步加载方案

RN中,发布js代码时,会打包成jsbundle形式。随着业务的增大,jsbundle体积也会逐渐增大,特别是多Module场景下,会生成多个jsbundle(包含相同的基础)。不仅增加APP、热更新包体积,也对jsbundle的加载效率造成很大影响。针对jsbundle的拆包,成为集成RN必须考虑的问题。

拆包目的

  • 解决jsbundle体积过大
  • 按需分步加载,提高加载效率
  • 提高热更新包diff/load效率

react-native bundle 命令

react-native bundle是RN的将js代码打包成jsbundle命令。具体使用方式不再赘述,可以通过react-native bundle -help查看。对index.js进行打包,执行:

react-native bundle --entry-file ./index.js --dev false --bundle-output index.jsbundle --bundle-encoding utf-8 --platform "ios" 

以index.js为入口,将index.js以及dependence文件打包,在当前目录生成index.jsbundle。分析index.jsbundle文件(删减后):

// header:各依赖模块引用部分
var __DEV__=false,__BUNDLE_START_TIME__=this.nativePerformanceNow?nativePerformanceNow():Date.now(),process=this.process||{};process.env=process.env||{};process.env.NODE_ENV='production';
// body:入口模块和各业务模块定义部分
__d(function(e,r,a,i,l){var n=r(l[0]);babelHelpers.interopRequireDefault(n);r(l[1])},11,[12,17]);
// footer:入口模块注册部分
require(11);

生成的jsbundle基本分成3个部分:

  • 头部:全局定义,主要是define,require等全局模块的定义
  • 中部:模块定义,RN框架和业务的各个模块定义
  • 尾部:引擎初始化和入口函数执行
6DIP96ivBUlcwJOINfUO.jpg

__d是RN自定义的define,__d后面的数字是模块的id,是在RN打包过程中,解析依赖关系,自增长生成。__d结构:

// 一个基本的结构。注:v0.55,之前的版本define可能不同
__d(
    function(e,r,a,i,l){
        var n=r(l[0]);
        babelHelpers.interopRequireDefault(n);
        r(l[1])
    },
    11,//模块 id
    [12,17]
);
// define
        __d(
          function(global, ...) { (module transformed code) },
          moduleId,
          dependencyMap?,
          moduleName?
        );

针对不同模块的入口js文件打包,将生成不同jsbundle对比,可以发现:

  • jsbundle的头部相同
  • 中部很多模块的定义存在大量重复
  • 如果模块js中AppRegistry.registerComponent,尾部的入口模块id基本相同,如上例中的require(11)

实际上头部和中部重复的模块占用了500K的大小,每个入口js生成的jsbundle都会包含这500K代码。这就是我们拆包需要解决的一个主要问题之一。

拆包方案

对RN的bundle命令有了了解,我们可以理清一个思路:将共通基础部分的模块define与具体的业务模块define分拆,避免重复写入jsbundle。
基于这个思路,有几种主流方案:

  1. diff and patch。将jsbundle通过diff,生成common和每个业务的patch包,然后在APP运行时对common和patch合并成执行的jsbundle。
  2. 修改RN的bundle命令打包流程,使得直接生成common+business包
  3. 修改RN的unbundle命令,生成common+business包

实际上,方案1是目前其他各种方案的基础,也是最简单的拆包方案。方案2、3需要较强的nodejs编码能力,另外,RN的升级非常平凡,变动较大,侵入式的生成方案成本较高。本篇只针对方案1做介绍,方案2、3另行介绍,也可参照文末链接,手Q、携程等解决方案。
:unbundle命令可以完成默认方式的拆包,生成以__d结构为单位的js文件集。但RN的unbundle不支持iOS平台。

common.js 共通模块定义

diffandpatch方案的基本原理,是将业务模块与共通模块jsbundle进行对比,获取差异性的patch。所以,common模块的定义很重要。一般只包含基本的react引用:

// common.js
import React from 'react'; 
import {} from 'react-native';

以common.js 为--entry-file打包,生成common.js:

react-native bundle --entry-file ./common.js --dev false --bundle-output common.jsbundle --bundle-encoding utf-8 --platform "ios" 

这样,对RN基础模块的define就全部生成在common.jsbundle中。

businese.js 业务模块定义

这里要注意的是,业务js的入口文件,最好将common.js的代码加入到头部,保持与common.js一致:

// business.js
import React from 'react'; 
import {} from 'react-native';
/*
* business contents
*/

这样打包出来的business.jsbundle,common部分模块的id会保持一致,否则可能出现很多id不一致的情况。

business.jsbundle与common.jsbundle 做diff分拆

这里有两种不同的diff方式。

1. 简单diff patch

直接对business.jsbundle与common.jsbundle做diff,将差异点生成patch文件。
APP运行加载business时,将common.jsbundle和business.patch直接merge合成business.jsbundle。
这种方式简单,实现快。针对热更新,也可以通过patch的方式实现。但问题是,虽然可以避免common模块重复打包进jsbundle的问题,但在APP运行时,merge后的business.jsbundle仍然摆脱不了体积大、加载缓慢的问题。

2. 针对__d模块define的diff

简单diff方案无法解决加载缓慢的问题,本质还是到客户端,一次load整个业务jsbundle的方式问题。如果有一种方式,可以动态注入新的jsbundle,那么,就可以分步加载js模块。

2.1 RCTBridge JS动态注入实现

RN本质是通过jscontext来实现js代码的加载和执行。RN与js的交互方式需要了解底层实现。对于jsbundle加载来说,在native侧,一个重要的地方,是RCTBridge对象的使用。


2824296-d2765ca19b7d4b51.png

RCTRootView内部持有了一个RCTBridge,但是这个RCTBridge并没有太多的代码,而是持有了另一个RCTBatchBridge对象,大部分的业务逻辑都转发给BatchBridge,BatchBridge里面写着的大量的核心代码。
我们可以通过initWithBundleURL初始化时,或者注册delegate方式,将jsbundle赋值给bridge:

//  RCTBridge 初始化
- (instancetype)initWithBundleURL:(NSURL *)bundleURL
                   moduleProvider:(RCTBridgeModuleListProvider)block
                    launchOptions:(NSDictionary *)launchOptions
// delegate
- (instancetype)initWithDelegate:(id<RCTBridgeDelegate>)delegate
                   launchOptions:(NSDictionary *)launchOptions

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge

此时的jscontext环境已经注入了加载的jsbundle。考察BatchBridge的方法列表,我们发现一个私有方法,可以在BatchBridge初始化jsbundle后,动态注入新的jsbundle

- (void)enqueueApplicationScript:(NSData *)script
                             url:(NSURL *)url
                      onComplete:(dispatch_block_t)onComplete;

我们可以通过extension的方式,将方法公开:

@interface RCTBridge()

@property(atomic, strong) RCTBridge *batchedBridge;

- (void)enqueueApplicationScript:(NSData *)script
                             url:(NSURL *)url
                      onComplete:(dispatch_block_t)onComplete;

@end

这样就可以在后续向BatchBridge注入新的jsbundle:

// self.bridge = [[RCTBridge alloc] initWithBundleURL:commonJSLocation moduleProvider:nil launchOptions:nil];
NSString *businessPath = [[NSBundle mainBundle] pathForResource:@"business" ofType:@"jsbundle"];
NSData *busJSCode = [NSData businessPath];
[self.bridge.batchedBridge enqueueApplicationScript:busJSCode url:[NSURL businessPath] onComplete:^{
                dispatch_async(dispatch_get_main_queue(), ^{
                    RNFuncViewController *vc = [[RNFuncViewController alloc] initWithModuleName:@"Business" bridge:self.bridge];
                    [self.navigationController pushViewController:vc animated:YES];
                });
            }];

如此,我们就实现了jsbundle的动态注入。

2.2 js注入为目的的分拆

如果直接拿上面简单分拆后的common和business jsbundle直接进行分步加载,RN会load报错。这是因为,通过两次react-native bundle打出的jsbundle,会存在相同模块id的define,产生冲突。diff需要遵循几个策略:
2.2.1. 对比common.jsbundle,对于business.jsbundle中多出的__d,需要设定一个唯一的start_id,从start_id开始,增量的对新的__d的模块id做替换。例如:


1529055374940.jpg
// index2.js
import React from 'react';
import {AppRegistry} from 'react-native';

import App2 from './App2';

AppRegistry.registerComponent('App2', () => App2);

common和index2 存在两个差异__d(红色为差异点)。其中第一个差异__d的模块id相同,都为11(实际上入口模块id):

// common.jsbundle 差异部分
__d(function(e,r,a,i,l){var n=r(l[0]);babelHelpers.interopRequireDefault(n);r(l[1])},11,[12,17]);
// index2.jsbundle 差异部分
__d(function(e,r,t,n,p){var i=r(p[0]),u=(babelHelpers.interopRequireDefault(i),r(p[1])),l=r(p[2]),a=babelHelpers.interopRequireDefault(l);u.AppRegistry.registerComponent('App2',function(){return a.default})},11,[12,17,307]);
__d(function(e,t,r,n,l){Object.defineProperty(n,"__esModule",{value:!0});var o=t(l[0]),a=babelHelpers.interopRequireDefault(o),s=t(l[1]),i=(function(e){function t(){return babelHelpers.classCallCheck(this,t),babelHelpers.possibleConstructorReturn(this,(t.__proto__||Object.getPrototypeOf(t)).apply(this,arguments))}return babelHelpers.inherits(t,e),babelHelpers.createClass(t,[{key:"render",value:function(){return a.default.createElement(s.View,{style:c.container},a.default.createElement(s.Text,{style:c.welcome},"Welcome to React Native!"),a.default.createElement(s.Text,{style:c.instructions},"To get started, edit AppNext.js"),a.default.createElement(s.Text,{style:c.instructions},"Press Cmd+R to reload,\nCmd+D or shake for dev menu"))}}]),t})(o.Component);n.default=i;var c=s.StyleSheet.create({container:{flex:1,justifyContent:'center',alignItems:'center',backgroundColor:'#F5FCFF'},welcome:{fontSize:20,textAlign:'center',margin:10},instructions:{textAlign:'center',color:'#333333',marginBottom:5}})},307,[12,17]);

设定index2.jsbundle的start_id为1000,做替换:

// 替换后的index2.jsbundle 差异点
__d(function(e,r,t,n,p){var i=r(p[0]),u=(babelHelpers.interopRequireDefault(i),r(p[1])),l=r(p[2]),a=babelHelpers.interopRequireDefault(l);u.AppRegistry.registerComponent('App2',function(){return a.default})},1000,[12,17,1001]);
__d(function(e,t,r,n,l){Object.defineProperty(n,"__esModule",{value:!0});var o=t(l[0]),a=babelHelpers.interopRequireDefault(o),s=t(l[1]),i=(function(e){function t(){return babelHelpers.classCallCheck(this,t),babelHelpers.possibleConstructorReturn(this,(t.__proto__||Object.getPrototypeOf(t)).apply(this,arguments))}return babelHelpers.inherits(t,e),babelHelpers.createClass(t,[{key:"render",value:function(){return a.default.createElement(s.View,{style:c.container},a.default.createElement(s.Text,{style:c.welcome},"Welcome to React Native!"),a.default.createElement(s.Text,{style:c.instructions},"To get started, edit AppNext.js"),a.default.createElement(s.Text,{style:c.instructions},"Press Cmd+R to reload,\nCmd+D or shake for dev menu"))}}]),t})(o.Component);n.default=i;var c=s.StyleSheet.create({container:{flex:1,justifyContent:'center',alignItems:'center',backgroundColor:'#F5FCFF'},welcome:{fontSize:20,textAlign:'center',margin:10},instructions:{textAlign:'center',color:'#333333',marginBottom:5}})},1001,[12,17]);

注意,第一个__d的模块id替换为1000,第二个替换为1001。同时,因为第一个dependence到了第二个,所以在dependencyMap中将原来的307改为1001。
index2.jsbundle中,删除其他行代码,只保留上面两条修改过的__d,保存为新的index2.jsbundle。

2.2.2 修改require
实际上运行上面新的jsbundle,仍然会报错。这是因为,我们还未修改入口require。报错信息可能跟AppRegistry相关。
在jsbundle的尾部,是模块的调用入口。对比可以看到,common和index2的入口id都是11。现在index2中id11改为1000,所以,需要在index2中增加id为1000的入口。最终index2.jsbundle:

__d(function(e,r,t,n,p){var i=r(p[0]),u=(babelHelpers.interopRequireDefault(i),r(p[1])),l=r(p[2]),a=babelHelpers.interopRequireDefault(l);u.AppRegistry.registerComponent('App2',function(){return a.default})},1000,[12,17,1001]);
__d(function(e,t,r,n,l){Object.defineProperty(n,"__esModule",{value:!0});var o=t(l[0]),a=babelHelpers.interopRequireDefault(o),s=t(l[1]),i=(function(e){function t(){return babelHelpers.classCallCheck(this,t),babelHelpers.possibleConstructorReturn(this,(t.__proto__||Object.getPrototypeOf(t)).apply(this,arguments))}return babelHelpers.inherits(t,e),babelHelpers.createClass(t,[{key:"render",value:function(){return a.default.createElement(s.View,{style:c.container},a.default.createElement(s.Text,{style:c.welcome},"Welcome to React Native!"),a.default.createElement(s.Text,{style:c.instructions},"To get started, edit AppNext.js"),a.default.createElement(s.Text,{style:c.instructions},"Press Cmd+R to reload,\nCmd+D or shake for dev menu"))}}]),t})(o.Component);n.default=i;var c=s.StyleSheet.create({container:{flex:1,justifyContent:'center',alignItems:'center',backgroundColor:'#F5FCFF'},welcome:{fontSize:20,textAlign:'center',margin:10},instructions:{textAlign:'center',color:'#333333',marginBottom:5}})},1001,[12,17]);
require(1000);

再次运行RN,发现可以实现分步加载common和business。

分布加载多个业务

按照上面的拆包方法,可以进一步将多个不同business拆包。按需加载business.jsbundle。native端加载对应business.jsbundle后,按照对应的moduleName来调用business。
为进一步提高加载效率,可以考虑bridge池缓存预加载好common.jsbundle的bridge,进入具体的business,注入具体的business.jsbundle。

本文介绍了RN中拆包以及分步加载jsbundle的思路。理解拆包,重要点还是在jsbundle的构成,以及RN与js的交互原理上。本文介绍的方案都很基础,如需实际操作,还需要相应的脚本协助,以及native端加载管理流程。

参考资料:
携程是如何做React Native优化的
React_Native拆分bundle之patch拆包
基于拆分包的React Native在iOS端加载性能优化
React Native按需加载 手Q狼人杀探索之路

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容