跳到主要内容

渲染、提交和挂载

注意

本文档引用了正在积极推行中的新架构

React Native 渲染器会执行一系列工作,将 React 逻辑渲染到宿主平台。这一系列工作被称为渲染管线,在初始渲染和 UI 状态更新时都会发生。本文档将介绍渲染管线及其在这些场景下的不同之处。

渲染管线可以分为三个主要阶段:

  1. 渲染: React 执行产品逻辑,在 JavaScript 中创建React 元素树。渲染器从该树中在 C++ 中创建React 阴影树
  2. 提交: 在 React 阴影树完全创建后,渲染器会触发一次提交。这会将 React 元素树和新创建的 React 阴影树都**提升**为待挂载的“下一个树”。这也会安排其布局信息的计算。
  3. 挂载: 包含布局计算结果的 React 阴影树被转换为宿主视图树

渲染管线的各个阶段可能发生在不同的线程上。有关更多详细信息,请参阅线程模型文档。

React Native renderer Data flow


初始渲染

想象一下您想渲染以下内容:

jsx
function MyComponent() {
return (
<View>
<Text>Hello, World</Text>
</View>
);
}

// <MyComponent />

在上面的示例中,<MyComponent /> 是一个React 元素。React 递归地将此*React 元素*规约到最终的React 宿主组件,方法是调用它(或其render方法,如果用 JavaScript 类实现),直到每个*React 元素*不能再被规约。现在您有了一个React 宿主组件的*React 元素树*。

阶段 1. 渲染

Phase one: render

在此元素规约过程中,当每个*React 元素*被调用时,渲染器还会同步地创建一个React 阴影节点。这只发生在*React 宿主组件*上,不发生在React 复合组件上。在上面的示例中,<View>导致创建一个ViewShadowNode对象,而<Text>导致创建一个TextShadowNode对象。值得注意的是,从没有直接表示<MyComponent>的*React 阴影节点*。

每当 React 在两个*React 元素节点*之间创建父子关系时,渲染器也会在相应的*React 阴影节点*之间创建相同的关系。这就是*React 阴影树*的组装方式。

附加详情

  • 这些操作(*React 阴影节点*的创建,两个*React 阴影节点*之间父子关系的创建)是同步且线程安全的操作,它们从 React (JavaScript) 执行到渲染器 (C++),通常在 JavaScript 线程上。
  • *React 元素树*(及其组成的*React 元素节点*)并非无限期存在。它是 React 中由“光纤”具体化的一种临时表示。每个代表宿主组件的“光纤”都存储一个指向*React 阴影节点*的 C++ 指针,这得益于 JSI。在此文档中了解更多关于“光纤”的信息。
  • *React 阴影树*是不可变的。为了更新任何*React 阴影节点*,渲染器会创建一个新的*React 阴影树*。但是,渲染器提供了克隆操作,以使状态更新更高效(有关更多详细信息,请参阅React 状态更新)。

在上面的示例中,渲染阶段的结果如下所示:

Step one

在*React 阴影树*完成后,渲染器会触发*React 元素树*的提交。

阶段 2. 提交

Phase two: commit

提交阶段由两个操作组成:*布局计算*和*树提升*。

  • 布局计算:此操作计算每个*React 阴影节点*的位置和大小。在 React Native 中,这涉及到调用 Yoga 来计算每个*React 阴影节点*的布局。实际计算需要每个*React 阴影节点*的样式,这些样式源自 JavaScript 中的*React 元素*。它还需要*React 阴影树*根的布局约束,这决定了结果节点可以占用的可用空间量。

Step two

  • 树提升(新树 → 下一个树):此操作将新的*React 阴影树*提升为待挂载的“下一个树”。此提升表示新的*React 阴影树*已具备所有可挂载的信息,并代表*React 元素树*的最新状态。“下一个树”将在 UI 线程的下一个“心跳”时挂载。

附加详情

  • 这些操作在后台线程上异步执行。
  • 大多数布局计算完全在 C++ 中执行。然而,某些组件的布局计算取决于*宿主平台*(例如 `Text`、`TextInput` 等)。文本的大小和位置是每个*宿主平台*特有的,需要在*宿主平台*层进行计算。为此,Yoga 会调用在*宿主平台*中定义的一个函数来计算组件的布局。

阶段 3. 挂载

Phase three: mount

挂载阶段将*React 阴影树*(现在包含布局计算的数据)转换为屏幕上带有渲染像素的*宿主视图树*。提醒一下,*React 元素树*看起来像这样:

jsx
<View>
<Text>Hello, World</Text>
</View>

概括而言,React Native 渲染器为每个*React 阴影节点*创建相应的宿主视图,并将其挂载到屏幕上。在上面的示例中,渲染器为<View>创建了android.view.ViewGroup实例,为<Text>创建了android.widget.TextView实例,并用“Hello World”填充它们。类似地,对于 iOS,会创建一个UIView并通过调用NSLayoutManager来填充文本。然后,每个宿主视图都配置为使用其 React 阴影节点的属性,并使用计算出的布局信息配置其大小和位置。

Step two

更详细地说,挂载阶段包括以下三个步骤:

  • 树差异化:此步骤完全在 C++ 中计算“先前渲染的树”和“下一个树”之间的差异。结果是一个要在宿主视图上执行的原子突变操作列表(例如,`createView`、`updateView`、`removeView`、`deleteView` 等)。此步骤也是 React 阴影树被扁平化以避免创建不必要的宿主视图的地方。有关此算法的详细信息,请参阅视图扁平化
  • 树提升(下一个树 → 已渲染树):此步骤原子地将“下一个树”提升为“先前渲染的树”,以便下一个挂载阶段针对正确的树计算差异。
  • 视图挂载:此步骤将原子突变操作应用于相应的宿主视图。此步骤在*宿主平台*的 UI 线程上执行。

附加详情

  • 操作在 UI 线程上同步执行。如果提交阶段在后台线程上执行,则挂载阶段会在 UI 线程的下一个“心跳”时调度。另一方面,如果提交阶段在 UI 线程上执行,则挂载阶段会在同一线程上同步执行。
  • 挂载阶段的调度、实现和执行在很大程度上取决于*宿主平台*。例如,Android 和 iOS 之间的挂载层渲染器架构目前有所不同。
  • 在初始渲染期间,“先前渲染的树”是空的。因此,树差异化步骤将生成一个仅包含创建视图、设置属性和相互添加视图的突变操作列表。当处理React 状态更新时,树差异化对于性能变得更加重要。
  • 在当前的生产测试中,一个*React 阴影树*通常包含大约 600-1000 个*React 阴影节点*(在视图扁平化之前),在视图扁平化之后,树被缩减到约 200 个节点。在 iPad 或桌面应用程序中,这个数量可能会增加 10 倍。

React 状态更新

让我们探究当*React 元素树*的状态更新时渲染管线的每个阶段。假设您在初始渲染中渲染了以下组件:

jsx
function MyComponent() {
return (
<View>
<View
style={{backgroundColor: 'red', height: 20, width: 20}}
/>
<View
style={{backgroundColor: 'blue', height: 20, width: 20}}
/>
</View>
);
}

应用初始渲染部分中描述的内容,您会期望创建以下树:

Render pipeline 4

请注意,**节点 3** 映射到一个**红色背景**的宿主视图,而**节点 4** 映射到一个**蓝色背景**的宿主视图。假设由于 JavaScript 产品逻辑中的状态更新,第一个嵌套 <View> 的背景从 'red' 变为 'yellow'。新的*React 元素树*可能看起来像这样:

jsx
<View>
<View
style={{backgroundColor: 'yellow', height: 20, width: 20}}
/>
<View
style={{backgroundColor: 'blue', height: 20, width: 20}}
/>
</View>

React Native 如何处理此更新?

当状态更新发生时,渲染器需要在概念上更新*React 元素树*,以便更新已挂载的宿主视图。但是为了保持线程安全,*React 元素树*和*React 阴影树*都必须是不可变的。这意味着 React 必须创建一个包含新属性、样式和子元素的新树副本,而不是修改当前的*React 元素树*和*React 阴影树*。

让我们探讨状态更新期间渲染管线的每个阶段。

阶段 1. 渲染

Phase one: render

当 React 创建一个包含新状态的*React 元素树*时,它必须克隆受更改影响的每个*React 元素*和*React 阴影节点*。克隆后,新的*React 阴影树*将被提交。

React Native 渲染器利用结构共享来最大程度地减少不可变性的开销。当一个*React 元素*被克隆以包含新状态时,向上到根路径上的每个*React 元素*都会被克隆。**React 只会在需要更新其属性、样式或子元素时克隆一个 React 元素。**任何未因状态更新而改变的*React 元素*都由旧树和新树共享。

在上面的例子中,React 使用这些操作创建新树:

  1. CloneNode(节点 3, {backgroundColor: 'yellow'}) → 节点 3'
  2. CloneNode(节点 2) → 节点 2'
  3. AppendChild(节点 2', 节点 3')
  4. AppendChild(节点 2', 节点 4)
  5. CloneNode(节点 1) → 节点 1'
  6. AppendChild(节点 1', 节点 2')

在这些操作之后,**节点 1'** 表示新*React 元素树*的根。我们把“先前渲染的树”称为**T**,把“新树”称为**T'**

Render pipeline 5

请注意 **T** 和 **T'** 如何都共享 **节点 4**。结构共享提高了性能并减少了内存使用。

阶段 2. 提交

Phase two: commit

在 React 创建新的*React 元素树*和*React 阴影树*后,必须提交它们。

  • 布局计算:类似于初始渲染期间的布局计算。一个重要的区别是布局计算可能导致共享的*React 阴影节点*被克隆。这可能发生,因为如果共享*React 阴影节点*的父节点发生布局更改,则共享*React 阴影节点*的布局也可能更改。
  • 树提升(新树 → 下一个树):类似于初始渲染期间的树提升。

阶段 3. 挂载

Phase three: mount

  • 树提升(下一个树 → 已渲染树):此步骤原子地将“下一个树”提升为“先前渲染的树”,以便下一个挂载阶段针对正确的树计算差异。
  • 树差异化:此步骤计算“先前渲染的树”(**T**)和“下一个树”(**T'**)之间的差异。结果是一个要在*宿主视图*上执行的原子突变操作列表。
    • 在上述示例中,操作包括:`UpdateView(**Node 3**, {backgroundColor: 'yellow'})`
    • 可以针对任何当前挂载的树与任何新树计算差异。渲染器可以跳过树的某些中间版本。
  • 视图挂载:此步骤将原子突变操作应用于相应的*宿主视图*。在上述示例中,只有**视图 3** 的backgroundColor将被更新(为黄色)。

Render pipeline 6


React Native 渲染器状态更新

对于*阴影树*中的大多数信息,React 是唯一的拥有者和唯一的真相来源。所有数据都源自 React,并且数据流是单向的。

然而,有一个例外和重要的机制:C++ 中的组件可以包含不直接暴露给 JavaScript 的状态,并且 JavaScript 不是真相的来源。C++ 和*宿主平台*控制着这个*C++ 状态*。通常,这只有在您正在开发一个需要*C++ 状态*的复杂*宿主组件*时才相关。绝大多数*宿主组件*不需要此功能。

例如,`ScrollView` 使用此机制告知渲染器当前偏移量。更新从*宿主平台*触发,特别是从表示`ScrollView`组件的宿主视图触发。关于偏移量的信息用于measure等 API。由于此更新源自宿主平台,并且不影响 React 元素树,因此此状态数据由*C++ 状态*持有。

从概念上讲,*C++ 状态*更新类似于上面描述的React 状态更新。有两个重要区别:

  1. 它们跳过“渲染阶段”,因为 React 不参与。
  2. 更新可以在任何线程上发起和发生,包括主线程。

阶段 2. 提交

Phase two: commit

当执行*C++ 状态*更新时,一段代码会请求更新一个`ShadowNode`(**N**),将*C++ 状态*设置为值**S**。React Native 渲染器会重复尝试获取**N**的最新已提交版本,用新状态**S**克隆它,并将**N’**提交到树中。如果 React 或另一个*C++ 状态*更新在此期间执行了另一个提交,则*C++ 状态*提交将失败,渲染器将多次重试*C++ 状态*更新,直到提交成功。这可以防止真相来源冲突和竞态条件。

阶段 3. 挂载

Phase three: mount

*挂载阶段*与React 状态更新的挂载阶段几乎相同。渲染器仍然需要重新计算布局,执行树差异比较等。有关详细信息,请参阅以上章节。