ReactNative启动过程简析

ReactNative(以下简称RN)是近年移动端非常火的技术。我们也在前段时间用RN实现了一个小的功能模块,上线效果还可以。因此,暂时对前一阶段的工作进行一下梳理和小结。这一系列将从原理、实践的角度谈谈RN,以及在实现过程中的一些经(da)验(keng)。
这篇文章,主要从启动流程的角度,谈谈在启动背后,RN都做了些什么。
对RN的探讨和总结都基于0.46.4版本,下同。

假定,我们已经通过react-native init命令,或是集成到现有项目中的方式,拥有了一个可以跑起来的RN项目了。当我们在xcode中点击了编译按钮,到最终,一个让人欣喜的app在模拟器或者设备中运行了起来,这中间发生了些什么呢。

Stage 1. 准备工作

首先,第一阶段和两个脚本有关。

React.xcodeproj工程的build phase中,可以看到Start Packager。这里会执行一段脚本:

1
2
3
4
5
6
7
8
9
10
if [ -z "${RCT_NO_LAUNCH_PACKAGER+xxx}" ] ; then
if nc -w 5 -z localhost 8091 ; then
if ! curl -s "http://localhost:8081/status" | grep -q "packager-status:running" ; then
echo "Port 8081 already in use, packager is either not running or not running correctly"
exit 2
fi
else
open "/Users/gaoyang/Documents/code/QRMedalHallRN/node_modules/react-native/scripts/launchPackager.command" || echo "Can't start packager automatically"
fi
fi

在这里执行了launchPackager.command,该命令会调用./local-cli/cli.jsstart命令,其最终走到./local-cli/server/runServer.js - runServer()中,创建了一个server,端口号默认为8081,用于接下来客户端请求本地js文件。

在主工程的build phase中,同样会执行一段脚本Bundle React Native code and images

1
2
export NODE_BINARY=node
/Users/gaoyang/Documents/code/QRMedalHallRN/node_modules/react-native/scripts/react-native-xcode.sh

这个脚本的作用主要是判断各种环境,条件,来判断是应当采用本地server模式,还是静态bundle模式来加载业务js文件。

  • 本地server模式,即从上面所说的创建的本地server实时获取经过编译/打包后的bundle文件;
  • 静态bundle模式,是指将业务js文件打包成bundle后,置入asset中,当做静态资源来使用。

首先,脚本会判断是dev/release环境,模拟器/真机。如果是dev环境+模拟器,那么什么都不会做,因为默认会采用本地server模式。如果是dev环境+真机,或者是release环境,那么会调用到./local-cli/cli.js bundle命令,将本地所有js文件打包成main.jsbundle,并置入app bundle中。此外,如果是dev环境+真机,还会获取当前网络ip地址,写入ip.txt并置入app bundle,这是为了真机调试也可以采用server的方式调试。如下图所示:



Stage 2. 获取bundle地址

接下来,就到了iOS代码中。加载RN业务的时候,我们得知道从哪里获取业务的bundle。其实这一步是和上面react-native-xcode.sh干的活儿对应的。这里逻辑主要在[RCTBundleURLProvider jsBundleURLForBundleRoot:fallbackResource:]方法中。该方法就是去获取业务所需的bundle路径。

该方法中,同样会判断是否是Dev/Release环境。如果是Release环境,那么直接从[NSBundle mainBundle]中获取上面生成好的main.jsbundle文件;如果是Dev环境,那么会去获取本地server的host地址,即上面写入的ip.txt文件,或者采用localhost

1
2
3
4
5
6
7
8
9
static NSString *ipGuess;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *ipPath = [[NSBundle mainBundle] pathForResource:@"ip" ofType:@"txt"];
ipGuess = [[NSString stringWithContentsOfFile:ipPath encoding:NSUTF8StringEncoding error:nil]
stringByTrimmingCharactersInSet:[NSCharacterSet newlineCharacterSet]];
});
NSString *host = ipGuess ?: @"localhost";

接着,会发送一条网络请求到该server,判断server是否处于running状态。如果这里失败了,那么还是会从[NSBundle mainBundle]中取静态的main.jsbundle文件;否则,就从server请求实时的bundle文件。

Stage 3. 初始化环境

在上一步,拿到了业务bundle地址后,就可以着手准备初始化环境了。这里分两种:一种是RN默认的不分包加载的方式,一种是业界采用的分包加载的方式。这两种方式的区别在于RCTBridge的初始化时机不同(RCTBridge是Native端负责Native与JS通信的桥梁):

  • 不分包加载

不分包加载的情况下,通过上一步取到业务bundle的地址后,就可以直接调用

1
2
3
4
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"QRMedalHallRN"
initialProperties:nil
launchOptions:launchOptions];

方法来初始化一个rootView了。在这个方法中会首先去初始化RCTBridge,在初始化的过程中会调用[RCTJavaScriptLoader loadBundleAtURL:onProgress:onComplete:]进行bundle的加载。

  • 分包加载

分包加载的情况下,通常会将common bundle进行预加载。RCTBridge在这时就已经初始化完成了。后面的各个业务bundle可以使用同一个RCTBridge来创建RCTRootView,不必重复创建。因此,在分包加载的情况下,通过上一步取到业务bundle的地址后,直接手动调用[RCTJavaScriptLoader loadBundleAtURL:onProgress:onComplete:]进行bundle的加载。

初始化RCTBridge的过程中,主要做了这些事:

  1. 创建了RN线程_jsThread,并开启了runloop
  2. 初始化了所有不能被懒加载的native模块。
  3. 初始化了NativeToJSBridgeJSToNativeBridge,用于Native端与JS端的通信。
  4. 创建了JSCExecutor,它是实际上的最主要的方法执行者。JSCExecutor创建了一个global的JSContext
  5. loadSource:也就是在这一步,调用了[RCTJavaScriptLoader loadBundleAtURL:onProgress:onComplete:]对bundle进行加载(不分包的情况下),主要是将bundle以NSData的方式加载到内存中。
  6. 在上述步骤都完成后,会调用executeSourceCode将已经加载到内存中的bundle执行。这一步是在[bridge enqueueApplicationScript:url:onComplete:completion]中进行的。
  7. 在第6步执行完毕后,会做两件事情:创建一个CADisplayLink,并添加到RN线程的runloop中;并抛出一个RCTJavaScriptDidLoadNotification,通知jsBundle已经完成加载。

关于RCTBridge具体的初始化细节,以及Native与JS通信的原理和过程,请参考我师父的文章^w^

RCTRootView在接收到RCTJavaScriptDidLoadNotification通知后,会创建一个RCTRootContentView,用于页面的实际展示。然后,会调用runApplication方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)runApplication:(RCTBridge *)bridge
{
NSString *moduleName = _moduleName ?: @"";
NSDictionary *appParameters = @{
@"rootTag": _contentView.reactTag,
@"initialProps": _appProperties ?: @{},
};
RCTLogInfo(@"Running application %@ (%@)", moduleName, appParameters);
[bridge enqueueJSCall:@"AppRegistry"
method:@"runApplication"
args:@[moduleName, appParameters]
completion:NULL];
}

该方法调用了js端,AppRegistry.jsrunApplication方法,并将moduleNameinitialProps等参数传到js端。其中,moduleName就是在业务入口文件index.ios.js中注册的业务名称:

1
AppRegistry.registerComponent('QRMedalHallRN', () => QRMedalHallRN);

w

Stage 4. 完成!

控制权交给js端。AppRegistry.js中保存了一份所有通过AppRegistry.registerComponent()注册的业务的映射表runnables,其key为appKey,也就是上面说的moduleName。每个key对应一个run()函数。

在收到Native端传过来的moduleNameinitialProps参数后,会从runnables中找到该注册过的业务,执行相应的run()函数。run()函数中最终调用了ReactNative.render()函数。接着,RN会根据配置,决定是采用新的React引擎Fiber,还是老的Stack来进行渲染。目前,还是使用老的Stack进行渲染,React 16中使用Fiber

ReactNativeStack-dev中,我们可以看到,最终调用到了mountComponent函数来进行渲染:

1
2
3
4
5
6
7
8
9
mountComponent: function(transaction, hostParent, hostContainerInfo, context) {
var tag = ReactNativeTagHandles_1.allocateTag();
this._rootNodeID = tag, this._hostParent = hostParent, this._hostContainerInfo = hostContainerInfo;
for (var key in this.viewConfig.validAttributes) this._currentElement.props.hasOwnProperty(key) && deepFreezeAndThrowOnMutationInDev(this._currentElement.props[key]);
var updatePayload = ReactNativeAttributePayload_1.create(this._currentElement.props, this.viewConfig.validAttributes), nativeTopRootTag = hostContainerInfo._tag;
return UIManager.createView(tag, this.viewConfig.uiViewClassName, nativeTopRootTag, updatePayload),
ReactNativeComponentTree_1.precacheNode(this, tag), this.initializeChildren(this._currentElement.props.children, tag, transaction, context),
tag;
}

可以看到,这里将代码控制权又交给了 Native 侧的 UIManager ,调用了 createView 方法,在 Native 侧进行页面、视图的创建等。

1
2
3
4
RCT_EXPORT_METHOD(createView:(nonnull NSNumber *)reactTag
viewName:(NSString *)viewName
rootTag:(__unused NSNumber *)rootTag
props:(NSDictionary *)props)

UIManagercreateView 方法也是 js 调用 Native 创建视图的入口。

至此,初始化工作完成。

总结

这篇文章归纳整理了一下RN初始化流程。虽然这些流程大部分都被封装成了几个简单的接口,但了解这一流程的原理还是有好处的,比如说,在分工程调试(一个主工程,一个RN单独的工程)的时候,可以修改一些参数,使得主工程可以直接读取到RN工程中的业务js代码。

刚刚接触RN(前端)的知识,如果有说的不对的地方还请指教。^^


Reference

ReactNative源码解析——通信机制详解系列

React Native 源码导读(零) – 创建/运行/调试