Fiber 协调算法

组件树与树的递归遍历

在传统的 React 处理中,组件是以树的形式存在的,这是很符合直觉的。例如对于这样的一段 JSX 代码:

1
2
3
4
5
6
7
8
9
<App>
<List>
<Item></Item>
</List>
<Detail>
<Information></Information>
<button></button>
</Detail>
</App>

抽象的看,其嵌套结构如下,是一棵 N 叉树:

组件树

那么,如果这棵组件树上的某个节点的 state 更新了,我们如何将 state 的更新应用到组件树上去呢?
一个简单的想法是递归整棵组件树,然后更新那些应该更新的节点。对于 N 叉树,我们很容易能写出递归遍历算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const preOrder = (root: TreeNode) => {
const result = [];

const visit = (node: TreeNode) => {
if (node) {
for (n of node.children) {
visit(n);
}

updateNode(node);
result.push(node.val);
}
};

visit(root);
return result;
};

对于这种处理办法,代码非常简单,我们只需要在递归到对应节点,然后根据 state 更新节点即可。
但这种处理的问题是,递归代码无法暂停。换句话说,一旦遇到需要长时间执行的,或是频繁变化的情况下,页面的阻塞感将会非常严重。

而 Fiber Tree 的引入则消除了这种问题。通过将树级关系实现为链表结构,将会大大节省内存,同时链表结构的处理可以随时暂停或恢复,无需阻塞。这与对树形结构栈递归遍历是有很大区别的。
从逻辑上讲,这实际上是将一整个连续的更新工作拆分为多个小的更新工作。这样可以大大提高渲染的流畅度。也就是下面这两幅图展示的:

Stack
Fiber

React 节点与 Fiber 节点

在经过 JSX 编译器处理后,我们将会得到一些 React 元素。你可以将其理解为低配版的 AST,它完整描述了 UI 所涉及到的所有元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[
{
$$typeof: Symbol(react.element),
type: 'button',
key: "1",
props: {
children: 'Update counter',
onClick: () => { ... }
}
},
{
$$typeof: Symbol(react.element),
type: 'span',
key: "2",
props: {
children: 0
}
}
]

每一个节点都通过 Symbol(react.element) 唯一的标识为 React 节点。另外节点对象上还有一些额外属性,它们描述了原元素所具有的功能。这个节点对象的结构取决于你的编译器设计。在大多数情况下,我们会希望在语义通的情况下复用一部分属性,从而简化编译器的实现。

初次生成的 AST 具有 JSX 模板的完整信息,但这并不意味着直接使用初始 AST 来生成 DOM 是一个好主意。
在编译中,最重要也是最精妙的部分实际上是对 AST 的优化,这直接决定了编译器的能力与性能。

在 React 中,策略是将原始的 React Element AST 转化成了 Fiber Tree。每一个 React Element 节点都有一个 Fiber 节点对应。从编译原理的思路来讲,这其实就是优化后的 AST。
值得注意的是,在每次 render 的时候,Fiber Tree 并不会重新生成。它仅仅是保存组件状态和 DOM 的可变数据结构。
更进一步的,从 Fiber Tree 的设计来讲,你可以认为 Fiber Tree 实际上代表了将要做的工作列表。其次,Fiber Tree 本身的设计也可以让工作可以被追踪、调度、暂停或恢复。

一棵 Fiber Tree 可能会像是这样:
Fiber Tree

每一个 Fiber Node 都有三个属性:

  • child
  • sibling
  • return

而在更新 Fiber Tree 时,使用了双重缓冲。也就是在内存中存储了两棵 Fiber 树,更新新树 (workInProgress),丢弃老树 (current)。

  • current: 应用程序当前的 UI 状态
  • workInProgress: 要刷新到屏幕的未来状态

从逻辑上讲,workInProgress 树实际上就是用户不可见的草稿。这样做的好处是隐藏了中间状态,且可以防止页面抖动。

其生成过程是,当遍历 current 树时,对于每个现有的 Fiber 节点,会创建一个新的 Fiber 节点,也就是 workInProgress 树的节点。这个节点创建的依据是 render 方法返回的 React Element 的数据。当所有更新工作完成后,我们也得到了一个完整的 workInProgress 树。一旦这棵树被渲染到了页面上后,我们就将 current 指针指向这棵树。

可以在这里看到完整的 Fiber Node 数据结构

副作用与副作用列表

在 React 的设计哲学中,组件就是一个以 props、state 为参数产出 UI 的纯函数。
所以其他活动,诸如渲染 DOM 等,都是副作用。而实际上,大多数 state 和 props 的更新都会导致副作用。所以 Fiber Tree 不仅仅是一种高效追踪副作用的机制。更准确的说,Fiber 节点上定义的副作用包含了处理节点更新后需要为实例完成的工作。
这种工作有可能是修改 DOM,也有可能是调用生命周期方法。

而为了最大限度的提高遍历具有副作用的 Fiber 节点的速度,React 构建了具有副作用的 Fiber 节点的线性列表。
假设对于如下 Fiber Tree:

Side-Effects Fiber Tree

假设我们的更新导致 c2 被插入到 DOM 中,d2 和 c1 更改了属性,而 b2 触发了声明周期方法。那么副作用列表将会将它们链接在一起,以便 React 可以跳过那些没有副作用的节点。

Side-Effects List

注意直线代表同层关系,折线代表父子关系

使用 firstEffect 指针就可以确定列表的开始位置。也即从子到父的顺序应用副作用。
同时注意到这些具有副作用的节点是通过 nextEffect 属性来连接,而不是 child 等属性。这样 React 就可以迅速跳过那些没有副作用的节点。

注意到我们有一个 Fiber Tree 的根节点,这实际上就是保存对 Fiber Tree 引用的地方。

Reference

Author: ShroXd
Link: http://www.bebopser.com/2021/06/21/fiberTree/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.