React Native 按需載入實戰(二)
上一篇文章介紹瞭如何對 React Native 專案的 JS 檔案進行拆包,這次我們仍然用一個例子來演示如何按需載入拆包後的各檔案。
目標
如上圖所示,最終想實現如下效果:
- 利用上一篇文章的方法,把 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
- 進入應用時,先載入執行
base.bundle.js
(包含react
及react-native
等基礎庫),然後載入執行home.bundle.js
,此時頁面顯示 home 相關的內容。 - 點選 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.js
和 home.bundle.js
,成功後會執行 runApplication
方法渲染頁面。
當點選 Go To Business1
按鈕時,會使用 UINavigationContoller
推入一個新的 MyRNViewController
,當 MyRNViewController
中的試圖載入完成後,會使用相同的 Bridge
載入執行 business1.bundle.js
,成功後會執行 runApplication
方法渲染頁面。
接下來詳細介紹一下。
AppDelegate.m 的改造
如上文所說,應用啟動的時候會初始化一個 MyRNViewController
並通過 UINavigationContoller
,這一步在 AppDelegate.m
的 application
方法中來實現:
- (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 會導致所有業務奔潰等問題。