渲染、提交和挂载
本文档指的是正在积极推广的新架构。
React Native 渲染器会经历一系列工作流程,将 React 逻辑渲染到宿主平台。这个工作流程序列称为渲染管道,发生在初始渲染和 UI 状态更新时。本文档将介绍渲染管道以及这两种情况下的差异。
渲染管道可以分为三个主要阶段
- 渲染 (Render): React 执行产品逻辑,在 JavaScript 中创建React 元素树。渲染器从这个树中在 C++ 中创建一个 React 影子树。
- 提交 (Commit): 在完全创建 React 影子树后,渲染器会触发提交。这会将 React 元素树和新创建的 React 影子树提升为要挂载的“下一个树”。 这也会安排计算其布局信息。
- 挂载 (Mount): React 影子树现在包含布局计算的结果,被转换为宿主视图树。
渲染管道的阶段可能发生在不同的线程上。 有关更多详细信息,请参阅线程模型文档。
初始渲染
假设您想要渲染以下内容
function MyComponent() {
return (
<View>
<Text>Hello, World</Text>
</View>
);
}
// <MyComponent />
在上面的示例中,<MyComponent />
是一个 React 元素。 React 递归地将这个React 元素 简化为终端 React 宿主组件,方法是调用它(或其 render
方法,如果使用 JavaScript 类实现),直到每个React 元素 都无法进一步简化。 现在您有了一个由 React 宿主组件组成的React 元素树。
阶段 1. 渲染
在这个元素简化的过程中,当每个React 元素 被调用时,渲染器也会同步创建一个React 影子节点。 这仅发生在React 宿主组件 上,而不是 React 复合组件。 在上面的示例中,<View>
导致创建了一个 ViewShadowNode
对象,而 <Text>
导致创建了一个 TextShadowNode
对象。 值得注意的是,永远不会有一个直接表示 <MyComponent>
的React 影子节点。
每当 React 在两个React 元素节点 之间创建父子关系时,渲染器也会在相应的React 影子节点 之间创建相同的关系。 这就是React 影子树 的组装方式。
更多细节
- 操作(创建React 影子节点,在两个React 影子节点 之间创建父子关系)是同步且线程安全的操作,从 React (JavaScript) 执行到渲染器 (C++),通常在 JavaScript 线程上。
- React 元素树(及其组成的React 元素节点)不会无限期存在。 它是由 React 中的“fibers”具体化的临时表示。 每个代表宿主组件的“fiber”都存储一个指向React 影子节点 的 C++ 指针,这由 JSI 实现。在此文档中了解更多关于 “fibers” 的信息。
- React 影子树 是不可变的。 为了更新任何React 影子节点,渲染器会创建一个新的React 影子树。 但是,渲染器提供克隆操作以使状态更新更高效(有关更多详细信息,请参阅React 状态更新)。
在上面的示例中,渲染阶段的结果如下所示
在 React 影子树 完成后,渲染器会触发 React 元素树 的提交。
阶段 2. 提交
提交阶段包括两个操作:布局计算 和树提升。
- 布局计算: 此操作计算每个React 影子节点 的位置和大小。 在 React Native 中,这涉及到调用 Yoga 来计算每个React 影子节点 的布局。 实际计算需要每个 React 影子节点 的样式,这些样式源自 JavaScript 中的 React 元素。 它还需要 React 影子树 根节点的布局约束,这决定了结果节点可以占用的可用空间量。
- 树提升(新树 → 下一个树): 此操作将新的 React 影子树 提升为要挂载的“下一个树”。 此提升表明新的 React 影子树 具有所有要挂载的信息,并代表 React 元素树 的最新状态。 “下一个树” 在 UI 线程的下一个“tick”上挂载。
更多细节
- 这些操作在后台线程上异步执行。
- 大多数布局计算完全在 C++ 中执行。 但是,某些组件的布局计算依赖于宿主平台(例如
Text
、TextInput
等)。 文本的大小和位置特定于每个宿主平台,需要在宿主平台 层上计算。 为此,Yoga 调用在宿主平台 中定义的函数来计算组件的布局。
阶段 3. 挂载
挂载阶段将React 影子树(现在包含来自布局计算的数据)转换为宿主 视图树,并在屏幕上渲染像素。 作为提醒,React 元素树 如下所示
<View>
<Text>Hello, World</Text>
</View>
在较高层面上,React Native 渲染器为每个 React 影子节点 创建一个相应的 宿主视图,并将其挂载到屏幕上。 在上面的示例中,渲染器为 <View>
创建了一个 android.view.ViewGroup
实例,为 <Text>
创建了一个 android.widget.TextView
实例,并用 “Hello World” 填充它。 类似地,对于 iOS,创建了一个 UIView
,并通过调用 NSLayoutManager
填充文本。 然后,配置每个宿主视图以使用其 React 影子节点的 props,并使用计算出的布局信息配置其大小和位置。
更详细地说,挂载阶段包括以下三个步骤
- 树 Diffing: 此步骤完全在 C++ 中计算“先前渲染的树”和“下一个树”之间的差异。 结果是要在宿主视图上执行的原子突变操作列表(例如
createView
、updateView
、removeView
、deleteView
等)。 此步骤也是将 React 影子树扁平化以避免创建不必要的宿主视图的地方。 有关此算法的详细信息,请参阅 视图扁平化。 - 树提升(下一个树 → 已渲染的树): 此步骤原子性地将“下一个树”提升为“先前渲染的树”,以便下一个挂载阶段计算与正确树的差异。
- 视图挂载: 此步骤将原子突变操作应用于相应的宿主视图。 此步骤在 UI 线程上的宿主平台 中执行。
更多细节
- 这些操作在 UI 线程上同步执行。 如果提交阶段在后台线程上执行,则挂载阶段将安排在 UI 线程的下一个“tick”中执行。 另一方面,如果提交阶段在 UI 线程上执行,则挂载阶段将在同一线程上同步执行。
- 挂载阶段的调度、实现和执行在很大程度上取决于宿主平台。 例如,Android 和 iOS 之间当前挂载层的渲染器架构有所不同。
- 在初始渲染期间,“先前渲染的树”为空。 因此,树 diffing 步骤将产生一个仅包含创建视图、设置 props 和将视图添加到彼此的突变操作列表。 当处理 React 状态更新时,树 diffing 对于性能变得更加重要。
- 在当前的生产测试中,React 影子树 通常由大约 600-1000 个 React 影子节点 组成(在视图扁平化之前),在视图扁平化之后,树减少到约 200 个节点。 在 iPad 或桌面应用程序上,此数量可能会增加 10 倍。
React 状态更新
让我们探讨一下当React 元素树 的状态更新时,渲染管道的每个阶段。 假设您在初始渲染中渲染了以下组件
function MyComponent() {
return (
<View>
<View
style={{backgroundColor: 'red', height: 20, width: 20}}
/>
<View
style={{backgroundColor: 'blue', height: 20, width: 20}}
/>
</View>
);
}
应用 初始渲染 部分中描述的内容,您会期望创建以下树
请注意,节点 3 映射到一个具有红色背景的宿主视图,而 节点 4 映射到一个具有蓝色背景的宿主视图。 假设由于 JavaScript 产品逻辑中的状态更新,第一个嵌套 <View>
的背景从 'red'
变为 'yellow'
。 这是新的 React 元素树 可能的样子
<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 影子树,新副本包含新的 props、样式和子项。
让我们探讨一下状态更新期间渲染管道的每个阶段。
阶段 1. 渲染
当 React 创建包含新状态的新 React 元素树 时,它必须克隆受更改影响的每个 React 元素 和 React 影子节点。 克隆后,新的 React 影子树 将被提交。
React Native 渲染器利用结构共享来最大限度地减少不可变性的开销。 当克隆 React 元素 以包含新状态时,路径上直到根的每个 React 元素 都会被克隆。 React 仅在 React 元素需要更新其 props、样式或子项时才会克隆它。 任何状态更新未更改的 React 元素 都由旧树和新树共享。
在上面的示例中,React 使用以下操作创建新树
- CloneNode(节点 3,
{backgroundColor: 'yellow'}
) → 节点 3' - CloneNode(节点 2) → 节点 2'
- AppendChild(节点 2', 节点 3')
- AppendChild(节点 2', 节点 4)
- CloneNode(节点 1) → 节点 1'
- AppendChild(节点 1', 节点 2')
在这些操作之后,节点 1' 代表新 React 元素树 的根。 让我们将 T 分配给“先前渲染的树”,将 T' 分配给“新树”
请注意 T 和 T' 如何都共享 节点 4。 结构共享提高了性能并减少了内存使用。
阶段 2. 提交
在 React 创建新的 React 元素树 和 React 影子树 后,它必须提交它们。
- 布局计算: 类似于 初始渲染 期间的布局计算。 一个重要的区别是,布局计算可能会导致共享的 React 影子节点 被克隆。 发生这种情况的原因是,如果共享的 React 影子节点 的父节点发生布局更改,则共享的 React 影子节点 的布局也可能发生更改。
- 树提升(新树 → 下一个树): 类似于 初始渲染 期间的树提升。
阶段 3. 挂载
- 树提升(下一个树 → 已渲染的树): 此步骤原子性地将“下一个树”提升为“先前渲染的树”,以便下一个挂载阶段计算与正确树的差异。
- 树 Diffing: 此步骤计算“先前渲染的树”(T)和“下一个树”(T')之间的差异。 结果是要在宿主视图 上执行的原子突变操作列表。
- 在上面的示例中,操作包括:
UpdateView(**节点 3**, {backgroundColor: 'yellow'})
- 可以为任何当前挂载的树与任何新树计算差异。 渲染器可以跳过树的一些中间版本。
- 在上面的示例中,操作包括:
- 视图挂载: 此步骤将原子突变操作应用于相应的宿主视图。 在上面的示例中,只会更新 视图 3 的
backgroundColor
(变为黄色)。
React Native 渲染器状态更新
对于 影子树 中的大多数信息,React 是唯一的拥有者和唯一的真理来源。 所有数据都源自 React,并且存在单向数据流。
但是,有一个例外和重要的机制:C++ 中的组件可以包含未直接暴露给 JavaScript 的状态,而 JavaScript 不是真理来源。 C++ 和宿主平台 控制着这种 C++ 状态。 通常,这仅在您开发需要 C++ 状态 的复杂宿主组件 时才相关。 绝大多数宿主组件 不需要此功能。
例如,ScrollView
使用此机制让渲染器知道当前的偏移量是多少。 更新由宿主平台 触发,特别是来自代表 ScrollView
组件的宿主视图。 有关偏移量的信息用于诸如 measure 之类的 API 中。 由于此更新源自宿主平台,并且不影响 React 元素树,因此此状态数据由 C++ 状态 保存。
从概念上讲,C++ 状态 更新类似于上面描述的 React 状态更新。 有两个重要的区别
- 它们跳过“渲染阶段”,因为 React 没有参与。
- 更新可以源自并发生在任何线程上,包括主线程。
阶段 2. 提交
当执行 C++ 状态 更新时,一段代码请求更新 ShadowNode
(N) 以将 C++ 状态 设置为值 S。 React Native 渲染器将重复尝试获取 N 的最新提交版本,使用新状态 S 克隆它,并将 N' 提交到树中。 如果 React 或另一个 C++ 状态 更新在此期间执行了另一个提交,则 C++ 状态 提交将失败,并且渲染器将多次重试 C++ 状态 更新,直到提交成功。 这可以防止真理来源冲突和竞争。
阶段 3. 挂载
挂载阶段 实际上与 React 状态更新的挂载阶段 相同。 渲染器仍然需要重新计算布局,执行树 diff 等。 有关详细信息,请参阅以上部分。