React Native 按需載入實戰(二)

語言: CN / TW / HK

上一篇文章介紹瞭如何對 React Native 專案的 JS 檔案進行拆包,這次我們仍然用一個例子來演示如何按需載入拆包後的各檔案。

目標

如上圖所示,最終想實現如下效果:

  1. 利用上一篇文章的方法,把 React Native 應用打包成三個 bundle :

其中 base.bundle.js 僅包括基礎庫:

import 'react'
import 'react-native'

home.bundle.js 包括的內容如下:

// 打包入口檔案 index.js
import {AppRegistry} from 'react-native'
import Home from './App'

AppRegistry.registerComponent('home', () => Home)

// App.js
import React, {useEffect} from 'react'
import {View, Text, Button, StyleSheet, NativeModules} from 'react-native'

const Home = () => {
  return (
    <View>
      <View>
        <Text>Home</Text>
      </View>
      <View>
        <Button
          title='Go To Business1'
          onPress={() => {
            NativeModules.Navigator.push('business1')
          }}
        />
      </View>
    </View>
  )
}

export default Home

注意,這裡我們實現了一個 Native Module Navigator ,後面會介紹。

business1.bundle.js 包括的內容如下:

// 打包入口檔案 index.js
import {AppRegistry} from 'react-native'
import Business1 from './App'

AppRegistry.registerComponent('business1', () => Business1)

// App.js
import React, {useEffect} from 'react'
import {View, Text, StyleSheet, Alert} from 'react-native'

const Business1 = () => {
  useEffect(() => {
    Alert.alert(global.name)
  }, [])
  return (
    <View>
      <Text>Business1</Text>
    </View>
  )
}
export default Business1
  1. 進入應用時,先載入執行 base.bundle.js (包含 reactreact-native 等基礎庫),然後載入執行 home.bundle.js ,此時頁面顯示 home 相關的內容。
  2. 點選 home 頁面上的 Go To Business1 跳轉到 business1 頁面,此時會載入執行 business1.bundle.js ,然後顯示 business1 頁面。

前置知識

Objective—C 語法簡單介紹

Objective—C(以下簡稱 OC)是一門強型別語言,需要宣告變數的型別:

NSSttring * appDelegateClassName;

OC 中函式呼叫非常奇怪,是用 [] 包起來的:

self.view.backgroundColor = [UIColor whiteColor];

OC 中也支援函式作為引數:

[Helper loadBundleWithURL:bundleUrl onComplete:^{
  [Helper runApplicationOnView:view];
}]

OC 中也有類的概念:

// 類宣告檔案 ViewController.h(繼承自 UIViewController)
@interface ViewController : UIViewController
 ...
@end

// 類實現檔案 ViewController.m
@implementation ViewController
- (void)viewDidLoad {

}
@end

更多知識請自行補充。

iOS 開發簡單介紹

UIView

UIView 是最基礎的檢視類,管理螢幕上一定的內容展示,作為各種檢視型別的父類,提供一些基礎的能力,如外觀、事件等,它可以佈局和管理子檢視。下面這個例子實現了在當前頁面上新增了一個黃色的子檢視:

@implementation ViewController
- (void)viewDidLoad {
  [super viewDidLoad];
  self.view.backgroundColor = [UIColor redColor];
  // subview
  UIView *view = [[UIView alloc] init];
  view.frame = CGRectMake(50, 50, 100, 50);
  view.backgroundColor = [UIColor yellowColor];
  [self.view addSubview:view];
}
@end

UIViewController

UIViewController 是檢視控制器,用於管理檢視的層級結構,它自身預設包含一個檢視。他可以管理檢視的生命週期,檢視之間的切換等,且 UIViewController 又可以管理別的 UIViewController 。這裡有個 例子 通過 UINavigationController 實現了兩個 UIViewController 之間的切換:

React Native 頁面載入流程介紹

首先,我們通過以下命令新建一個 RN 專案:

npx react-native init demo

我們先來看看入口檔案 index.js

import {AppRegistry} from 'react-native'
import App from './App'
import {name as appName} from './app.json'

AppRegistry.registerComponent(appName, () => App)

顯然,需要了解 AppRegistry.registerComponent 是做了什麼,我們來看看:

/**
   * Registers an app's root component.
   *
   * See http://reactnative.dev/docs/appregistry.html#registercomponent
   */
  registerComponent(
    appKey: string,
    componentProvider: ComponentProvider,
    section?: boolean,
  ): string {
    let scopedPerformanceLogger = createPerformanceLogger();
    runnables[appKey] = {
      componentProvider,
      run: (appParameters, displayMode) => {
        renderApplication(
          componentProviderInstrumentationHook(
            componentProvider,
            scopedPerformanceLogger,
          ),
          appParameters.initialProps,
          appParameters.rootTag,
          wrapperComponentProvider && wrapperComponentProvider(appParameters),
          appParameters.fabric,
          showArchitectureIndicator,
          scopedPerformanceLogger,
          appKey === 'LogBox',
          appKey,
          coerceDisplayMode(displayMode),
          appParameters.concurrentRoot,
        );
      },
    };
    if (section) {
      sections[appKey] = runnables[appKey];
    }
    return appKey;
  },

從上面的程式碼來看,它只是將元件存放在了 runnables 中,並沒有真正的渲染。那什麼時候渲染呢?這就需要看看 native 的程式碼了,我們開啟 ios 目錄下的 AppDelegate.m 檔案,可以看到:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  ...
  RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
  RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge
                                                   moduleName:@"rnDemo"
                                            initialProperties:nil];
  ...
}

第一行程式碼會進行 bridge 的初始化等工作,然後會非同步載入 JS 檔案並執行。也就是會執行 AppRegistry.registerComponent

第二行程式碼會準備一個檢視容器用於渲染,並會監聽 JS 檔案載入。當載入成功時,會呼叫 JS 程式碼中的 AppRegistry.runApplication

- (void)runApplication:(RCTBridge *)bridge
{
  NSString *moduleName = _moduleName ?: @"";
  NSDictionary *appParameters = @{
    @"rootTag" : _contentView.reactTag,
    @"initialProps" : _appProperties ?: @{},
  };

  RCTLogInfo(@"Running application %@ (%@)", moduleName, appParameters);
  // 呼叫 JS 程式碼中的方法
  [bridge enqueueJSCall:@"AppRegistry" method:@"runApplication" args:@[ moduleName, appParameters ] completion:NULL];
}

而 JS 程式碼中的 AppRegistry.runApplication 會執行 runnables 中相應的 run 方法最終進行頁面的渲染:

runApplication(
    appKey: string,
    appParameters: any,
    displayMode?: number,
  ): void {
    ...
    runnables[appKey].run(appParameters, displayMode);
  },

實現

介紹了這麼多準備知識以後,我們終於要開始實現我們的按需載入了,首先介紹一下整體方案。

方案設計

如圖所示,在應用啟動的時候我們會初始化一個 MyRNViewController 並通過 UINavigationContoller 來管理。當 MyRNViewController 中的試圖載入完成後,會通過 Bridge 載入並執行 base.bundle.jshome.bundle.js ,成功後會執行 runApplication 方法渲染頁面。

當點選 Go To Business1 按鈕時,會使用 UINavigationContoller 推入一個新的 MyRNViewController ,當 MyRNViewController 中的試圖載入完成後,會使用相同的 Bridge 載入執行 business1.bundle.js ,成功後會執行 runApplication 方法渲染頁面。

接下來詳細介紹一下。

AppDelegate.m 的改造

如上文所說,應用啟動的時候會初始化一個 MyRNViewController 並通過 UINavigationContoller ,這一步在 AppDelegate.mapplication 方法中來實現:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  ...
  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  MyRNViewController *vc =  [[MyRNViewController alloc] initWithModuleName:@"home"];
  self.window.rootViewController = [[UINavigationController alloc] initWithRootViewController:vc];
  [self.window makeKeyAndVisible];
  return YES;
}

MyRNViewController

// MyRNViewController 初始化後會自動呼叫
- (void)loadView {
  RCTRootView *rootView = [Helper createRootViewWithModuleName:_moduleName
                                                    initialProperties:@{}];
  self.view = rootView;
}

MyRNViewController 初始化的時候會自動呼叫 loadView 這個方法,該方法建立了一個 RCTRootView 並作為該 ViewController 的預設檢視:

+ (RCTRootView *) createRootViewWithModuleName:(NSString *)moduleName initialProperties:(NSDictionary *)initialProperties {
  // _sharedBridge 全域性共享的 RCTBridge
  // 如果還未初始化則初始化
  if (!_sharedBridge) {
    [self createBridgeWithURL:[NSURL URLWithString:baseUrl]];
  }

  RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:_sharedBridge
                                                   moduleName: moduleName
                                            initialProperties: initialProperties];
  return rootView;
}

MyRNViewController 的預設檢視載入完成後會執行 viewDidLoad 方法:

// MyRNViewController 中的試圖載入完成後
- (void)viewDidLoad {
  NSString *randStr = [Helper randomStr:2];
  NSURL *moduleUrl = [NSURL URLWithString:[NSString stringWithFormat:@"http://127.0.0.1:8080/%@.bundle.js?v=%@", _moduleName, randStr]];
  [Helper loadBundle:moduleUrl runAppOnView:self.view];
}

該方法中會去載入執行對應的 bundle 並在當前 view 上執行 runApplication 方法:

+ (void) loadBundle:(NSString *)bundleUrl runAppOnView:(RCTRootView*)view {
  // 確保 base bundle 只加載一次
  if (needLoadBaseBundle) {
    [Helper loadBundleWithURL:[NSURL URLWithString:baseUrl] onComplete:^{
      needLoadBaseBundle = false;
      [Helper loadBundleWithURL:bundleUrl onComplete:^{
        [Helper runApplicationOnView:view];
      }];
    }];
  } else {
    [Helper loadBundleWithURL:bundleUrl onComplete:^{
      [Helper runApplicationOnView:view];
    }];
  }
}

+ (void) loadBundleWithURL:(NSURL *)bundleURL onComplete:(dispatch_block_t)onComplete {
  [_sharedBridge loadAndExecuteSplitBundleURL2:bundleURL onError:^(void){} onComplete:^{
    NSLog([NSString stringWithFormat: @"%@", bundleURL]);
    onComplete();
  }];
}

+ (void) runApplicationOnView:(RCTRootView *)view {
  [view runApplication:_sharedBridge];
}

這裡 loadAndExecuteSplitBundleURL2 是在 react-native 原始碼中新加的方法,同時還把 (void)runApplication:(RCTBridge *)bridge; 宣告為了公共方法,供外部使用。詳情見 這裡

按需載入

當我們點選 Go To Business1 按鈕的時候會觸發按需載入,這個又是如何實現的呢?我們來看看 home 頁面的程式碼:

import {View, Text, Button, StyleSheet, NativeModules} from 'react-native';
...
<Button
  title='Go To Business1'
  onPress={() => {
    NativeModules.Navigator.push('business1')
  }}
/>
...

這裡我們其實是實現了一個 Native Module Navigator

// Navigator.h
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface Navigator : NSObject <RCTBridgeModule>

@end
// Navigator.m
#import <UIKit/UIKit.h>

#import "Navigator.h"
#import "Helper.h"
#import "MyRNViewController.h"

@implementation Navigator

RCT_EXPORT_MODULE(Navigator);

/**
 * We are doing navigation operations, so make sure they are all on the UI Thread.
 * Or you can wrap specific methods that require the main queue like this:
 */
- (dispatch_queue_t)methodQueue
{
  return dispatch_get_main_queue();
}

RCT_EXPORT_METHOD(push:(NSString *)moduleName) {
  MyRNViewController *newVc = [[MyRNViewController alloc] initWithModuleName:moduleName];
  [[Helper getNavigationController] showViewController:newVc sender:self];
}

@end

當呼叫 push 方法時,會向 UINavigationContoller 中新推入一個 MyRNViewController ,之後的邏輯就跟上面說過的類似了。

總結

基於上一篇文章的研究成果,本文對一個實際的 React Native 例子進行了拆包。然後通過改寫 native 端的程式碼,實現了對不同業務包的按需載入。專案完整程式碼 在此

不過,當前只是簡單實現,演示過程而已,實際上還有很多優化可以做:

  • 當前載入過的 bundle 並沒有快取起來,每次都會重新去下載。加上快取後,還可以做差量更新的優化,即每次釋出最新的 bundle 時,計算其與之前版本的差異,客戶端載入 bundle 時僅需要在舊 bundle 上應用差異的部分。
  • 所有 bundle 執行在同一個上下文之中,存在全域性變數汙染、某個 bundle 執行 crash 會導致所有業務奔潰等問題。