React Native 拆包实践5 - 按需加载js module

首先,明确一下需求。App启动时加载一个RN的主应用,当点击主应用中的一个Button后,打开一个RN子应用,这个子应用可以理解为微信小程序。

拆分jsbundle

根据上一节的内容,使用自定义的createModuleIdFactory将所有js module赋予了一个固定Id,在此基础上便可以开始拆包了,即将原先单独的jsbundle文件拆分为多个jsbundle文件。根据需求,我们的目标是将jsbundle分为base.jsbundlemain_app.jsbundlesub_app.jsbundle。顾名思义,base中包含了react native自有的js,三方库中的js以及各个app中一些公用的js,main_app和sub_app则只包含各自的业务代码。

这个过程中所需的命令行如下所示:

react-native bundle --platform ios --dev false --entry-file base.js --bundle-output build/ios/common.jsbundle --assets-dest build/ios/

react-native bundle --platform ios --dev false --entry-file index.js --bundle-output build/ios/main_app_all.jsbundle --assets-dest build/ios/

react-native bundle --platform ios --dev false --entry-file rewards/index.js --bundle-output build/ios/sub_app_all.jsbundle --assets-dest build/ios/

node diff.js ./build/ios/common.jsbundle ./build/ios/main_app_all.jsbundle ./build/ios/main_app.jsbundle
node diff.js ./build/ios/common.jsbundle ./build/ios/sub_app_all.jsbundle ./build/ios/sub_app.jsbundle

前三个命令用于打包jsbundle,不同点在于指定了不同的入口文件以及输出文件的路径。
其中base.js中定义了所有的公共模块(react native自有的js,三方库中的js以及各个app中一些公用的js)

// react-native js & 3rd part lib js
require("react-native");
require("react");
require("react-navigation");
require("react-native-gesture-handler");
require("react-native-reanimated");
require("react-navigation-hooks");

// reused js module
require("./src/components/Header")

index.jsrewards/index.js则为两个App的入口定义,两个打包命令打出的jsbundle都是可以直接在production环境下使用的。

其后的两条命令则是将main_app_all.jsbundle和sub_app_all.jsbundle中,与common.jsbundle相同的部分删除,从而得到只包含各自页面代码的main_app.jsbundle和sub_app.jsbundle。diff的代码比较简单,这里就不过多解释了:

var fs = require('fs');
var readline = require('readline');

function readFileToArr(fReadName, callback) {
  var fRead = fs.createReadStream(fReadName);
  var objReadline = readline.createInterface({
      input:fRead
  });
  var arr = new Array();
  objReadline.on('line',function (line) {
      arr.push(line);
  });
  objReadline.on('close',function () {
      callback(arr);
  });
}

var argvs = process.argv.splice(2);
var commonFile = argvs[0]; // common.jsbundle
var businessFile = argvs[1]; // business.jsbundle
var diffOut = argvs[2]; // diff.jsbundle
readFileToArr(commonFile, function (c_data) {
  var diff = [];
  var commonArrs = c_data;
  readFileToArr(businessFile, function (b_data) {
    var businessArrs = b_data;
    for (let i = 0; i < businessArrs.length; i++) {
      if (commonArrs.indexOf(businessArrs[i]) === -1) {
        diff.push(businessArrs[i]);
      }
    }
    var newContent = diff.join('\n');
    fs.writeFileSync(diffOut, newContent);
  });
});

经过这一系列操作,得到产物为:

  1. common.jsbundle
  2. main_app.jsbundle
  3. sub_app.jsbundle
  4. assets -- include all the images

将这些产物添加进项目,即可开始native部分的编码。通过需求,我们知道需要按需加载这三个jsbundle,而common.jsbundle是需要在app启动的初期就完成加载的,在其成功后可以根据需要加载main_app或sub_app。在我们的例子中,将优先显示main_app中的内容。

在我们没有进行任何修改时,AppDelegate中加载jsbundle的代码如下所示:

...
RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
                                                 moduleName:@"split_jsbundle"
                                          initialProperties:nil];
...
// 其后将rootView赋值给rootViewController的view

...
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge {
#if DEBUG
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
#else
  return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}

native部分按需加载jsbundle

先来逐行分析这些代码的功能:

  1. RCTBridge的初始化:RCTBridge作为js和native通信的唯一桥梁需要在第一时间进行初始化,同时RCTBridge实例在rn项目中也是以单例的形式存在的。
    RCTBridge提供了两个初始化方法,一个是基于RCTBridgeDelegate,另一个是基于bundle url。而两者在内部实现时,均调用了
- (instancetype)initWithDelegate:(id<RCTBridgeDelegate>)delegate
                       bundleURL:(NSURL *)bundleURL
                  moduleProvider:(RCTBridgeModuleListProvider)block
                   launchOptions:(NSDictionary *)launchOptions {...}

在完成一些赋值操作后,调用了setUp方法,忽略一些log相关的配置,在其中初始化了另一个bridge,即RCTCxxBridge。会发现几乎所有的RCTBridge最终的实施者都是RCTCxxBridgesetUp最核心的部分也是通过RCTCxxBridgestart方法完成的。这里附上该方法的实现,并忽略掉一些非核心功能的代码:

- (void)start
{
...
  // init JS thread
...
  dispatch_group_t prepareBridge = dispatch_group_create();

// register native modules
  [self registerExtraModules];
...
  __weak RCTCxxBridge *weakSelf = self;

  // Prepare executor factory (shared_ptr for copy into block)
...

  // Dispatch the instance initialization as soon as the initial module metadata has
  // been collected (see initModules)
  dispatch_group_enter(prepareBridge);
  [self ensureOnJavaScriptThread:^{
    [weakSelf _initializeBridge:executorFactory];
    dispatch_group_leave(prepareBridge);
  }];

  // Load the source asynchronously, then store it for later execution.
  dispatch_group_enter(prepareBridge);
  __block NSData *sourceCode;
  [self loadSource:^(NSError *error, RCTSource *source) {
    if (error) {
      [weakSelf handleError:error];
    }
    sourceCode = source.data;
    dispatch_group_leave(prepareBridge);
  } onProgress:^(RCTLoadingProgress *progressData) {
    // display loading progress ...
  }];

  // Wait for both the modules and source code to have finished loading
  dispatch_group_notify(prepareBridge, dispatch_get_global_queue(QOS_CLASS_USER_INTERACTIVE, 0), ^{
    RCTCxxBridge *strongSelf = weakSelf;
    if (sourceCode && strongSelf.loading) {
      [strongSelf executeSourceCode:sourceCode sync:NO];
    }
  });
}

可以看到,这个方法最主要的目的就是将jsbundle加载进内存,也就是dispatch_group_notify中的那部分代码,执行了executeSourceCode。进入该方法的实现:

- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync
{
  // This will get called from whatever thread was actually executing JS.
  dispatch_block_t completion = ^{
    // Log start up metrics early before processing any other js calls
    [self logStartupFinish];
    // Flush pending calls immediately so we preserve ordering
    [self _flushPendingCalls];

    // Perform the state update and notification on the main thread, so we can't run into
    // timing issues with RCTRootView
    dispatch_async(dispatch_get_main_queue(), ^{
      [[NSNotificationCenter defaultCenter]
       postNotificationName:RCTJavaScriptDidLoadNotification
       object:self->_parentBridge userInfo:@{@"bridge": self}];

      // Starting the display link is not critical to startup, so do it last
      [self ensureOnJavaScriptThread:^{
        // Register the display link to start sending js calls after everything is setup
        [self->_displayLink addToRunLoop:[NSRunLoop currentRunLoop]];
      }];
    });
  };

  if (sync) {
    [self executeApplicationScriptSync:sourceCode url:self.bundleURL];
    completion();
  } else {
    [self enqueueApplicationScript:sourceCode url:self.bundleURL onComplete:completion];
  }
...
}

它有两种模式,同步和异步,但结果都是调用了completionblock,在其中发出了一个通知RCTJavaScriptDidLoadNotification,表明加载已完成。

通过上述分析,我们得到了两个重要的信息,其一,初始化bridge时,完成了jsbundle的加载;其二,加载是通过调用executeSourceCode实现的;其三,加载完成是通过Notification的方式通知外界的。

  1. RCTRootView的初始化,先看代码:
- (instancetype)initWithBridge:(RCTBridge *)bridge
                    moduleName:(NSString *)moduleName
             initialProperties:(NSDictionary *)initialProperties
{
  ...
  if (self = [super initWithFrame:CGRectZero]) {
    self.backgroundColor = [UIColor whiteColor];

    _bridge = bridge;
    _moduleName = moduleName;
    _appProperties = [initialProperties copy];
    _loadingViewFadeDelay = 0.25;
    _loadingViewFadeDuration = 0.25;
    _sizeFlexibility = RCTRootViewSizeFlexibilityNone;

    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(bridgeDidReload)
                                                 name:RCTJavaScriptWillStartLoadingNotification
                                               object:_bridge];

    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(javaScriptDidLoad:)
                                                 name:RCTJavaScriptDidLoadNotification
                                               object:_bridge];

    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(hideLoadingView)
                                                 name:RCTContentDidAppearNotification
                                               object:self];

    [self showLoadingView];

    // Immediately schedule the application to be started.
    // (Sometimes actual `_bridge` is already batched bridge here.)
    [self bundleFinishedLoading:([_bridge batchedBridge] ?: _bridge)];
  }
  ...
  return self;
}

通过代码可以看到,它和初始化UIView没有什么本质区别,它本身也是UIView的一个子类。不同之处在于在初始化时注册了三个通知的监听事件,其中最核心的便是RCTJavaScriptDidLoadNotification通知,也是RCTCxxBridge在完成jsbundle加载后发出的通知。收到通知后,它调用了:

[bridge enqueueJSCall:@"AppRegistry"
               method:@"runApplication"
                 args:@[moduleName, appParameters]
           completion:NULL];

AppRegistry是JS运行所有React Native应用的入口。应用的根组件应当通过AppRegistry.registerComponent方法注册自己,然后原生系统才可以加载应用的代码包并且在启动完成之后通过调用AppRegistry.runApplication来真正运行应用。

根据我们的需求,common.jsbundle是要在启动时加载,在加载完成后再加载main_app.jsbundle来显示主页面。由上述代码可见,初始化bridge时,会加载指定的jsbundle,我们可以通过bridge的初始化来加载common.jsbundle。在获取到RCTJavaScriptDidLoadNotification通知后,再使用executeSourceCode来加载main_app.jsbundle。由于executeSourceCode是一个私有方法,就需要添加一个Category来调用它,具体代码如下:

@interface RCTBridge (RnLoadJS)
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;
@end

接下来我们再来看看这个完整的AppDelegate:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  [[NSNotificationCenter defaultCenter] addObserver:self
                                           selector:@selector(javaScriptDidLoad:)
                                               name:RCTJavaScriptDidLoadNotification
                                             object:nil];
  NSURL *main = [[NSBundle mainBundle] URLForResource:@"common" withExtension:@"jsbundle"];
  RCTBridge *bridge = [[RCTBridge alloc] initWithBundleURL:main moduleProvider:nil launchOptions:launchOptions];
  [[RNJSLoader sharedInstance] setupBridge:bridge];
  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  self.window.rootViewController = [self loadingViewController];
  [self.window makeKeyAndVisible];
  return YES;
}

- (void)javaScriptDidLoad:(NSNotification *)notification {
  [[NSNotificationCenter defaultCenter] removeObserver:self];
  NSURL *main = [[NSBundle mainBundle] URLForResource:@"main_app" withExtension:@"jsbundle"];
  ReactViewController *rootVC = [[ReactViewController alloc] initWithModuleName:@"split_jsbundle" url:main];
  self.rootNavigationController = [[UINavigationController alloc] initWithRootViewController:rootVC];
  [self.rootNavigationController setNavigationBarHidden:YES animated:NO];
  self.window.rootViewController = self.rootNavigationController;
}

- (UIViewController *)loadingViewController {
  UIViewController *vc = [[UIViewController alloc] init];
  vc.view.backgroundColor = [UIColor whiteColor];
  return vc;
}

其中涉及到两个辅助类ReactViewControllerRNJSLoader。代码如下所示:
ReactViewController:

#import <React/RCTBridge.h>
#import <React/RCTRootView.h>
#import "ReactViewController.h"
#import "RNJSLoader.h"

@interface ReactViewController ()
@property (nonatomic, strong) NSURL* url;
@property (nonatomic, strong) NSString* name;
@end

@implementation ReactViewController

- (instancetype)initWithModuleName:(NSString *)name url:(NSURL *)url {
  if (self = [super init]) {
    self.url = url;
    self.name = name;
  }
  return self;
}

- (void)viewDidLoad {
  [super viewDidLoad];
  self.view.backgroundColor = [UIColor whiteColor];
  if ([[RNJSLoader sharedInstance] loadJSModule:self.name path:self.url]) {
    [self initView];
  }
}

- (void)initView {
  RCTBridge *bridge = [RNJSLoader sharedInstance].bridge;
  RCTRootView* view = [[RCTRootView alloc] initWithBridge:bridge moduleName:self.name initialProperties:nil];
  view.frame = self.view.bounds;
  view.backgroundColor = [UIColor whiteColor];
  [self setView:view];
}

@end

RNJSLoader:

// .h file
#import <React/RCTBridge.h>

@interface RNJSLoader : NSObject

@property(nonatomic, strong, readonly)RCTBridge *bridge;
@property(nonatomic, strong, readonly)NSDictionary<NSString *, NSString *> *existingModues;

+ (instancetype)sharedInstance;
- (void)setupBridge:(RCTBridge *)bridge;
- (BOOL)loadJSModule:(NSString *)name path:(NSURL *)url;

@end

// .m file
#import "RNJSLoader.h"
#import <React/RCTBridge+Private.h>

@interface RCTBridge (RnLoadJS)
- (void)executeSourceCode:(NSData *)sourceCode sync:(BOOL)sync;
@end

@interface RNJSLoader ()
@property(nonatomic, strong) RCTBridge *bridge;
@property(nonatomic, strong) NSDictionary<NSString *, NSString *> *existingModues;
@property(nonatomic, strong) NSMutableDictionary<NSString *, NSURL *> *jsModules;
@end

@implementation RNJSLoader

+ (instancetype)sharedInstance {
  static RNJSLoader *sharedInstance = nil;
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
      sharedInstance = [[self alloc] init];
  });
  return sharedInstance;
}

- (instancetype)init {
  if (self = [super init]) {
    self.jsModules = [NSMutableDictionary dictionary];
    self.existingModues = @{@"split_jsbundle": @"main_app", @"rewards": @"sub_app"};
  }
  return self;
}

- (void)setupBridge:(RCTBridge *)bridge {
  self.bridge = bridge;
}

- (BOOL)loadJSModule:(NSString *)name path:(NSURL *)url {
  if (!self.bridge) {
    return NO;
  }
  NSURL *cachedUrl = [self.jsModules objectForKey:name];
  if (cachedUrl) {
    return YES;
  }
  NSError *error = nil;
  NSData *sourceData = [NSData dataWithContentsOfURL:url options:NSDataReadingMappedIfSafe error:&error];
  if (error) {
    return NO;
  }
  
  [self.bridge.batchedBridge executeSourceCode:sourceData sync:NO];
  [self.jsModules setValue:url forKey:name];
  return YES;
}

@end

React Native 触发 加载sub_app.jsbundle

由于触发是发生在JS端,所以这里就需要一个react native的native module来实现该功能了,这里没有什么特别需要说明的,直接上代码:

// .h file
#import <React/RCTBridgeModule.h>
@interface RNNavigation : NSObject<RCTBridgeModule>
@end

// .m file
#import "RNNavigation.h"
#import "AppDelegate.h"
#import "RNJSLoader.h"
#import "ReactViewController.h"

@implementation RNNavigation

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(navigateTo:(NSString *)name)
{
  NSString *bundleName = [[[RNJSLoader sharedInstance] existingModues] objectForKey:name];
  if (!bundleName) {
    return;
  }
  AppDelegate *delegate = (AppDelegate *)UIApplication.sharedApplication.delegate;
  NSURL *app = [[NSBundle mainBundle] URLForResource:bundleName withExtension:@"jsbundle"];
  ReactViewController *vc = [[ReactViewController alloc] initWithModuleName:name url:app];
  [delegate.rootNavigationController pushViewController:vc animated:YES];
}

RCT_EXPORT_METHOD(goBack)
{
  AppDelegate *delegate = (AppDelegate *)UIApplication.sharedApplication.delegate;
  [delegate.rootNavigationController popViewControllerAnimated:YES];
}

- (dispatch_queue_t)methodQueue {
  return dispatch_get_main_queue();
}

@end

总结

到此为止,已经实现了iOS端拆包并加载jsbunle。完整的代码请移步:https://github.com/iossocket/split_jsbundle

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

推荐阅读更多精彩内容