等等…当我的 React Native 启动时都发生了什么?深入了解 React Native(翻译)

2019-02-25

前言

了解 React Native 内部是如何运作的,以及它在你不知情的情况下为你做了什么。

本文的原作者是 Nicolas Couvrat,文章源链接,我将其翻译为中文。

免责声明

本文假设你对 React Native 和 Native Modules 有(非常)基本的了解。如果您从未接触过它们,我建议您先查看官方文档

编辑:这篇文章基于我 2018 年 3 月在伦敦 ReactFest 会议上的演讲内容。你可以在这里观看演讲内容。

在我刚使用 React Native 的时候,有的事很快让我困扰:这里到底发生了什么?事实上,对于那些只是拿 React Native 构建应用的人来说,对 React Native 内部感觉很神奇。想要让 Native 代码也能在 JavaScript 中被调用吗?只需要使用 @ReactMethod(在 iOS 下是 RCT_EXPORT_METHOD)!想从 Native 侧向 JavaScript 侧发送一个事件吗?没问题,只需要拿到对应的 JavaScript 模块,然后像调用原生方法一样去调用它!更不用说的是,这段 JavaScript 既运行在 iOS 上,业运行在 Android……

我们能够猜到,事实并不简单。实际上,其背后的实现是非常复杂的。那么,是什么使 React Native 能够实现这样的壮举呢?最简练和普遍的回答是:

React Native 桥(Bridge)!

换句话说:

这种简单含糊的答案并不能让我们满足。这一次我们将深入到细节中。但是需要注意的是:由于太过于发杂,事情可能非但没有梳理清晰,反而变得一团糟。

React Native 基础架构

让我们开门见山地说:为了正常工作,React Native 是在运行时构建出完整基础架构。铛铛!

等等… 你这不是又给了我们一个含糊其辞的解释吗?

我有吗?在这里有两点需要注意。首先,基础架构:著名的 Bridge 知识其中的一部分,我敢说它都不是最不可思议的部分。第二,运行时:在执行任何业务代码之前,每次启动 React Native 应用,都要先构建上述的基础架构。换句话说,在你的业务程序运行可见之前,需要经过一个过渡状态,React Native 正忙着为你构建应用的基础设施。

这个所谓的基础设施长什么样子呢?好吧,我们画一幅地图,展示它的各个部分(箭头的大概含义是“持有一份引用”):

其中:Java logo 可以被替换为 iOS 平台下的 Objective-C。

哇……是有点复杂,不是吗?这还是简化版本呢……为了理解这一堆乱七八糟的,我们将逐一描述各个部分,按照他们启动时被创建的顺序。我们开始吧!

启动一个 React Native 应用

还记得整个基础设施是在 React Native 应用启动时进行构建的吗?我们把它拆分一下,从你再手机屏幕上点击应用图标开始,到应用界面展示出来,我们将其拆分为几个步骤。

最初,你的应用只有两件事情要处理:

  • 应用的代码
  • 唯一一个线程——主线程。这是由手机操作系统自动分配的。

为了简化解释,我们从概念上将代码分为两部分:框架代码——这部分代码不是你写的——业务代码——你写的应用逻辑。这两部分代码都既包含 JavaScript 代码,也包含 Native 代码,这样总共出现 4 部分代码。首先将被处理的——是主线程上——框架代码的 Native 部分。

注意:主线程也叫 UI 线程,除了作为初始化线程之外,它将主要负责 UI 相关工作。

创建 Native 基础

需要意识到一个重要的事情是,大多数 UI 代码——<View><Text>……——是使用 JavaScript 编写的,他们最终将被展示为纯 Native 视图。也就是说,这意味着 React Native 框架需要:

  1. 创建原生视图并映射到 JavaScript 组件
  2. 存放这些原生是图,并展示他们

其中,第一步由 UIManagerModule 模块(将在下文讲解)处理,RootView 将负责第二步。RootView 是一个视图容器,其中包含原生视图,原生视图被组织在一个大的树形结构中——这棵树是 JavaScript 组件树的 Native 表示,如果你愿意的话——手机屏幕中所有元素都存放在其中。

回到我们的初始化过程:一切都从上述的 RootView 初始化开始——现在是一个空的容器——下一步该初始化桥接口(Bridge Interface)。

Bridge 不是建立在 Native 和 JavaScript 之间吗?这里说的接口是什么意思呢?

就是这个意思!但是,尽管大多数原生代码——包括 RootView 是使用平台特定语言(Objective-C 或者 Java)编写的,但是 Bridge 完全使用 C++ 编写的。桥接口(Bridge Interface)作为 API 的角色,允许两者之间进行交互。Bridge 本身由两部分组成:Native to JavaScript 以及 JavaScript to Native。

Bridge 的唯一作用就是向两头分发调用。一头是 Native Modules,最终将是另一头的 JavaScript 环境中唯一可用的东西。换句话说,JavaScript 应用程序最终只能看到 Native Modules。因此,除了你创建的 Custom Modules 之外,框架还包括 Core ModulesCore Modules 中的一个例子是 UIManagerModule,它存放有 JavaScript UI 组件和他们所对应的 Native 视图的映射。每次有 JavaScript UI 组件创建、更新或者删除, UIManagerModule 都会根据映射对 Native 视图进行相应的创建、更新或删除。它也会将 Native 视图的改动转发到 RootView 中存储的视图树,以便使它们可见。

从初始化角度来看,所有 Native Modules 都是一样的:对每个 Module,会被创建一个实例,以及一个到实例的引用,这个引用被存放在 JavaScript to Native Bridge 中——这样他们之后可以被 JavaScript 调用。除此,Bridge Interface 的引用也会传给每个 Native Module,让他们也能直接调用 JavaScript。最终,将创建两个额外的线程:JS ThreadNativeModulesThread

其中:严格来说,对于 React Native 的 iOS 实现,创建的不是一个线程,而是一个线程池。

Intermezzo(插曲):设置 JavaScript 引擎

在继续之前,让我们快速总结下到目前为止的进展:

  • 在主线程上创建了一堆 Native 的东西
  • 我们现在有了 3 条线程
  • 现在还完全没处理过任何 JavaScript

回到我们最初的地图,我们得到的是:

这意味着现在是加载 JavaScript 包的时候了——框架和业务代码!

JavaScript 是一门解释性的脚本语言,没法被直接运行:它需要被转化到字节码之后才能运行。这是 Javascript virtual machine (即 JavaScript 引擎)的工作。有很多 JavaScript 引擎,包括 Chrome 的 V8,Mozilla 的 SpiderMonkey 和 Safari 的 JavaScriptCore…如果处于调试模式,React Native 会使用 V8,并直接运行在浏览器中,除此,默认使用 JavaScriptCore 并在设备上运行。顺便说一句,Android 默认是没有 JavaScriptCore 的(iOS 上包含),因此 React Native 会在 Android 应用中自动捆绑一份 JavaScriptCore 运行时的拷贝——这使得 Android 应用程序要比 iOS 应用程序稍微重一些。

无论如何,在有效启动 JavaScript 引擎之前,React Native 必须为其提供一个表示执行环节的上下文。这包括 JavaScript 全局对象,这意味着这个全局对象实际上是在 C++ bridge 桥上创建和存储的。这一步为什么如此重要?因为全局对象不仅要在 JavaScript 环境中可访问,在外部环境中也要可访问。因此,它是 C++(Native)与 JavaScript 之间的主要通信方式,因为通过全局对象,一些 Native 方法将对 JavaScript 可用——这些函数反过来又用于 JavaScript 向 Native 回传数据。

全局对象中存有很多东西,其中 ModuleConfig 数组和 flushQueue() 方法格外重要。ModuleConfig 数组表述一个 Native Module(Core 或者 Custom),包括它的名称,它所暴露的常量、方法… flushQueue() 函数在确保 JavaScript 和 Native 环境之间的通信方面起着关键作用,因为他会被定期使用,将调用从前者传递到后者。

一旦 JavaScript Context 被完全创建,它被提供给 JavaScript 引擎,引擎开始在 JS Thread 上加载 React Native 的 JavaScript 包。

加载 JavaScript 包

当虚拟机开始处理框架代码的 JavaScript 部分的时候,它会创建 BatchedBridge。这个名字可能听起来很耳熟,因为它有时会在错误消息中弹出!尽管它名字很花哨,但它只是一个简单的UI列,存储“从 JavaScript 到 Native 的调用”。所谓的“调用”,指的是一个对象,它包含 module ID、method ID(针对特定的 Native Module),以及调用参数。周期性地(默认是每 5 秒钟),BatchedBridge 会调用 global.flushQueue(),传递内容——“调用”的数组——传给 C++ Bridge 的 JavaScript to Native 部分。作为批处理,这些小数组被索引,以确保一个批处理中包含的 UI 改变同时可见(这是必要的,因为整个流程是异步的)。bridge 的 JavaScript to Native 的 Native 侧将会最终遍历批数组中的每个调用,并将它们分发给对应的 Native Module,通过特定的 module ID ——之所以能这样做,是因为它有到每个 Native Module 的引用,还记得吗?

下一步是创建 NativeModules 对象,这个对象是你每次想调用 Native Module 时都要从 react-native 中导入的对象。NativeModules 对象将使用前面提到的 ModuleConfig 数组进行填充。我不打算在这里展开这个过程的细节,但是大体上来说相当于对每个 ModuleConfig 中的 module_name 进行 NativeModules[module_name]={},之后对每个 module 的导出方法进行 NativeModules[module_name][method_name]=fillerMethodfillerMethod 只用于存储它从 BatchedBridge 中接收到的参数,以及 method 和 module ID(类似于 fillerMethod = function(...args) { BatchedBridge.enqueueNativeCall(moduleID, methodID, args)} ),能够高效的创建一个 JavaScript to Native 的调用。这也就是说,当你之后编写 MyNativeModule.myMethod(args) 业务代码的时候,实际上是调用上面的 fillerMethod

我们分析地差不多了。最后一件要做的事情是创建核心 JS Modules,其中 DeviceEventEmitter——兼备用于从 Native 向 JavaScript 发送事件——或者 AppRegistry,它是由一个到应用主组件的引用。为了能从 Native 可调用,这些 modules 都被注册到了 JavaScript 的全局对象上……

至此,整个 React Native 基础架构就构建完成了!

让 React Native 应用可见

尽管初始化已经基本完成,但是我们的应用程序在这个阶段仍是不可见的!事实上,JavaScript 包的加载发生在 JS 线程,它独立于主(UI)线程。因此 JS 线程必须告知主线程完成它的任务,作为响应,主线程使用 AppRegistry(JS module)请求 JS 线程处理主自定义组件——通常是 App.js。

从线程角度聪姐,React Native 应用的启动过程是这样的:

包含在你的应用主组件中的 JavaScript 组件树将会被遍历,每遇到一个 UI 组件就调用 UIManagerModule。UIManagerModule(位于 UI 线程)将会依次负责创建 Native 视图,并在 RootView 中存放:祝贺,你的应用现在可见了!🎉 🎉

后记

本文基于我 2018 年三月在伦敦 ReactFest 的演讲。下面是在会议中我被问到的一些问题:

如果我们不使用 NativeModuleThread,那么创建它有什么意义呢?

这个线程确实在启动过程中没有主动地被使用。但是在后面的过程中它是很重要的,每个从 JavaScript 到 Native 的调用,都将——在被分发到对应的 Native Module 后——在这个线程上被执行。实现细节:在 React Native 的 iOS 版本,这个线程并不是作为线程存在的。而是每个 Native Module 在实例化时被提供一个 GCDQueue(由系统进行线程管理)。

为什么 Bridge 的两个方向(JavaScript to Native & Native to JavaScript)都是用 C++ 实现的?能不能一头用 JavaScript 实现,一头用 Native 实现。

这确实有点让人困惑。但是一旦我们搞清楚 React Native 的 Bridge 是来桥接哪个 Gap,就能明白了。这个 Gap 有两个方面:

  • 语言的缺口(Native、JavaScript)
  • 线程的缺口(JS 线程、主线程、NativeModuleThread)

语言的问题是,向我们前面总结的,主要通过 JavaScript 全局对象来解决,它在 JavaScript 和 C++ 环境中都可访问。因此,我们常说的 React Native 中的 Bridge 只是用来处理在不同线程上分发任务。在这方面,名称“native to Javascript”和“Javascript to native”意思就很明确了,尽管他们是使用同一种语言实现的:前者被 Native 线程(主线程或者 NativeModuleThread)调用,并将任务转发到 JS 线程,而后者从 JS 线程(使用 global.flushQueue() 方法)调用,将调用分发到 Native 线程。

小结

搞定!这是一份更加详细的,对 React Native 到底是如何驱动的概览。我希望这能触发你的好奇心,并且——谁知道呢——让你想要对框架做贡献?