跳到主内容

渲染、提交和挂载

注意

本文档指代正在积极推广的新架构

React Native 渲染器经过一系列工作,将 React 逻辑渲染到宿主平台。这一系列工作被称为渲染管道,它发生在初始渲染和 UI 状态更新时。本文档将详细介绍渲染管道及其在这些场景中的差异。

渲染管道可以分为三个通用阶段

  1. 渲染(Render):React 执行产品逻辑,在 JavaScript 中创建React 元素树。渲染器再根据此树在 C++ 中创建React 影子树
  2. 提交(Commit):React 影子树完全创建后,渲染器会触发一次提交。这会将 React 元素树和新创建的 React 影子树提升为将要挂载的“下一个树”。这也会调度其布局信息的计算。
  3. 挂载(Mount):React 影子树(现在包含布局计算结果)被转换为宿主视图树

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

React Native renderer Data flow


初始渲染

假设您要渲染以下内容

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

// <MyComponent />

在上面的例子中,<MyComponent /> 是一个React 元素。React 通过递归调用此 React 元素(如果使用 JavaScript 类实现,则调用其 render 方法),将其简化为最终的React 宿主组件,直到每个 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 中由“fiber”(纤程)实现的临时表示。每个代表宿主组件的“fiber”都存储一个指向 React 影子节点的 C++ 指针,这得益于 JSI。在此文档中了解有关“fiber”(纤程)的更多信息。
  • 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 线程的下一个“tick”(心跳/周期)上挂载。

更多详情

  • 这些操作在后台线程上异步执行。
  • 大多数布局计算完全在 C++ 内部执行。然而,某些组件的布局计算取决于宿主平台(例如 TextTextInput 等)。文本的大小和位置是每个宿主平台特有的,需要在宿主平台层上计算。为此,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 影子节点中的 props,其大小和位置则使用计算出的布局信息进行配置。

Step two

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

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

更多详情

  • 这些操作在 UI 线程上同步执行。如果提交阶段在后台线程上执行,则挂载阶段会安排在 UI 线程的下一个“tick”上。另一方面,如果提交阶段在 UI 线程上执行,则挂载阶段会在同一线程上同步执行。
  • 挂载阶段的调度、实现和执行在很大程度上取决于宿主平台。例如,挂载层的渲染器架构目前在 Android 和 iOS 之间存在差异。
  • 在初始渲染期间,“先前渲染的树”是空的。因此,树差异计算步骤将生成一个仅包含创建视图、设置 props 和相互添加视图的突变操作列表。在处理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 必须创建每个树的新副本,其中包含新的 props、样式和子元素,而不是直接修改当前的 React 元素树React 影子树

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

阶段 1. 渲染

Phase one: render

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

React Native 渲染器利用结构共享来最大限度地减少不可变性的开销。当一个 React 元素被克隆以包含新状态时,从该元素到根路径上的每个 React 元素都会被克隆。React 只会在需要更新其 props、样式或子元素时克隆一个 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

注意 TT' 都共享 节点 4。结构共享提高了性能并减少了内存使用。

阶段 2. 提交

Phase two: commit

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

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

阶段 3. 挂载

Phase three: mount

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

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

阶段 3. 挂载

Phase three: mount

挂载阶段实际上与React 状态更新的挂载阶段相同。渲染器仍然需要重新计算布局,执行树差异计算等。详细信息请参阅以上部分。