引言
正如官方文档所说的,React是一个渲染用户界面的库,而React应用中,界面视图对应了React中的fiber树(自v16以来),一切操作围绕fiber树,一切特性建立其上。那么,要深入理解React的各个特性就需要先理解fiber树。
fiber
fiber的设计
fiber树由多个fiber节点构成,所以要先谈fiber。Andrew Clark首次正式公开提出了fiber的设计,以下两段都总结自他的文章。
React Fiber的目标就是增强对动画,布局,交互的适配能力,进一步说就是赋予React调度能力。fiber的首要特性是增量式渲染,也就是把整个渲染工作打成多个工作单元。
其他重要特性则包括工作的中断,撤销和恢复能力和优先级的调度能力。
这里的关键是增量式渲染。
增量式渲染
先解释渲染。React应用了虚拟dom,fiber就是React的虚拟dom。渲染是获得虚拟dom树的过程,一个fiber的渲染就是获得它的子节点,全体fiber的渲染获得整棵树。fiber是有类型的,典型的类型包括funtiuon,class(就是你写的组件),二者各自的渲染就是函数的执行和render函数的执行。
再说增量式,基本上就是化整为零(总量并未减少),与全量式相对应。这里的关键是化整为零的部分之和要等于整体。一棵自然意义上的树,被切割成多个部分后再重新组装,得到的就不是原来那棵活着的树了。相反,一棵积木式的树,即使被拆成多个单元,经过组合,还是能够恢复为同一棵树。
每个fiber可以作为独立的渲染工作单元,就像积木中的元素。当所有的fiber被处理完成后,整棵fiber树就生成了。
增量式示例
下面有一个简单的对比示例,它是用纯Js实现的,展示了全量和增量的差异。先做一个简要的功能描述,这个应用会生成一个固定长度,元素从0-9随机生成的数组,随后会对这个数组各元素求和(刻意让求和变慢,方便做展示),在求和完成后把结果渲染出来。这个应用包含上中下三部分:
- 一个纯文本。
- 一个操作组,左边是一个生成数组然后求和的按钮,右边是一个模式切换单选钮。
- 渲染内容,包括生成的数组,以及作为结果的和。
第一个示例是全量式,第二个是增量式+可中断,这里用文本的选定代表用户交互操作(点击,滚动同理)。流程都是点击按钮开始计算和渲染,然后立刻尝试选取文本框。两种模式展示出了现象差异。
在全量式示例中,文本选定的结果在渲染结果完成后才出现,也就是说,从开始渲染到渲染完成之前用户的交互是得不到响应的。
而在增量式+可中断示例中,文本选定几乎“即时”(其实是在两个加和的间隙)响应了,并且渲染过程也随后完成了(实际上全程都是可响应用户交互事件的)。
这是一个简陋的模拟,你可以把数组看作fiber树,每个数组元素是一个fiber,每一次加和是一个独立的加和工作单元(与每个独立的fiber渲染工作单元对应),在全部元素参与加和后(对应于整个fiber树渲染完成),和作为真实dom被渲染到视图中(fiber树到真实dom的转换)。
这一切是怎么做到的呢?实现的代码也并不多:
let sum = 0;
let isIncremental = false; // 点击模式切换按钮会改变这个指
let isAsync = true; // 默认增量式下启用异步
const arrayDom = document.querySelector("#array");
const toggleDom = document.querySelector("#toggle");
const sumDom = document.querySelector("#sum");
function performSumIncremental(workArray, index) {
const nextIndex = index + 1;
const isFinished = workArray.length < nextIndex;
if (isFinished) {
commit();
return;
}
function performUnitOfSum(n) {
sleep(15);
sum += n;
}
performUnitOfSum(workArray[index]);
isAsync
? setTimeout(() => performSumIncremental(workArray, nextIndex))
: performSumIncremental(workArray, nextIndex);
}
function performSum(workArray) {
let currentSum = 0;
workArray.forEach((num) => {
sleep(15);
currentSum += num;
});
sum = currentSum;
commit();
}
function commit() {
sumDom.innerHTML = `${sum}`;
sum = 0;
}
function render() {
const array = spawnArray(150);
arrayDom.innerHTML = JSON.stringify(array);
sumDom.innerHTML = "";
setTimeout(() =>
isIncremental ? performSumIncremental(array, 0) : performSum(array)
);
}
// <button class="button" onclick="render()">生成数组,求和</button>
在这里,全量式下,求和是通过forEach迭代求得总和,一个函数完成。
增量式采用的是递归的方式,每次只处理一个元素,可中断情形以异步方式执行每个工作单元,不可中断情形以同步方式执行每个工作单元。
我制作了一张图片,便于理解。
从图中其实也能很容易理解到,增量式渲染不是细粒度更新,实际上总工作量是没有减少的,渲染所需的总时间是增加的。
增量式的关键是化整为零。在加和示例中,因为部分和加总等于总体和,所以工作可以单元化。在fiber上,单元化体现为,fiber具有指向其他fiber的链接,全部单个fiber的生成就是fiber树的生成。
可中断的关键是异步。事实上Js也是刻意设计为单线程的,异步执行的方式。在两个异步工作单元间隙里,浏览器可以处理用户的交互事件,从而实现响应。在全量式下,只能同步执行导致阻塞,没有额外的线程可以处理用户交互事件。增量式则可同步可异步。
在示例中用到的异步方式为setTimeout,而React中可中断更新用到的是messageChannel的onmessage回调。React不采用setTimeout和微任务的理由:
- 对于setTimeout,即便不指定延迟或者指定0秒延迟,默认还是会有几毫秒的延迟,这是额外的开销,对应在上面的时序图里的工作间隔。
- 对于微任务,Js的每一轮事件循环只会处理一个宏任务,但是会清空整个微任务队列。这意味着微任务的循环调用还是会导致阻塞,React的不可中断更新所使用的就是微任务。
此外React处理工作单元也并不是对每个fiber都单独异步处理(在加和示例中是这样),但基本原理还是异步,在以后讨论并发特性的文章里会有详细讨论。
那么工作的恢复呢?可以总结为两点:
- 记录中断点
- 在加和示例中,是作为函数实参的数组索引。
- 在React中,是全局的workInProgress指针。
- 有序的执行
- 在加和示例中,索引加一。
- 在React中,找到下一个fiber,一般是当前fiber的child或sibling。
fiber的生成
具体而言,fiber是一个Js对象,是从你写的jsx代码经过一系列转换得到的。对于React,人们很容易分不清组件,Element,虚拟dom,fiber,真实dom。
虚拟dom与真实dom对应,前者是UI的一种Js表现形式,后者是视图里的各个元素。vue也应用了虚拟dom,但是不同于fiber,React的虚拟dom就是fiber。
组件就是复合的funtion,class组件或者原生组件,它们在被jsx调用的时候就生成了Element。
在渲染阶段,React会根据Element生成对应的fiber。
上面是这种转换的一个简略图,示例中的组件是原生组件。
fiber的结构
作为一个Js对象,fiber的属性包含了一个组件的信息,如组件的输入和输出。具体的实细节有可能随时间调整,所以下面会介绍一些关键属性。
key,type
key和type对于fiber的意义与它们对于React Element的意义是相同的。在React根据Element创建fiber时,这两个属性是直接复制的。
key的作用就是为diff服务的。
type是对一个组件的描述。如果是人们写的复合组件,type对应的是相应的函数和类的引用;如果是原生组件,type是'div','button'这样的字符串;如果是Suspense,这样的功能组件,或者是memo,lazy包裹的组件,type就是各自对应的标识,数据类型各不相同。
对于不同的type的fiber,渲染逻辑是不同的。实际上,memo,Suspense本身不包含功能逻辑,在渲染阶段React会根据标识执行不同的逻辑。
child,sibling,return
这些属性都指向其它的fiber,正是各个fiber以及它们之间的指向关系,构成了整棵fiber树。
function Parent() {
return (
<>
<Child1 />
<Child2 />
</>
)
}
child属性指向了第一个子节点,sibling指向后面的兄弟节点,return则指向父节点。在上面的示例中Parent的child属性指向Child1对应的fiber,Child1的sibling属性指向Child2对应的fiber,Child1和Child2的return都指向Parent对应的fiber。
fiber之间的相互链接使得fiber树的渲染可以按序进行,即便这个过程中断了,在任何一个节点都能按预定的正确顺序恢复。
pendingProps,memoizedProps
props是函数组件或者类组件的props。penddingProps是在fiber渲染之初设定,在渲染结束后就转化为memoizedProps。如果penddingProps与memoizedProps相等(比较引用地址),就可以跳过渲染。
React每次渲染都是全量的,即便是增量式渲染,也只是把大的渲染整体拆成以单个fiber为单位进行的部分之和。但是显然你会发现,在一个组件setState更新时,它的父级以及兄弟组件并不会渲染,这是因为它们在更新中penddingProps与memoizedProps相等。同样,更新的组件以下的所有子组件,如果没有memo,也会出乎意料的渲染,这是因为他们的penddingProps与memoizedProps不相等。通过props的生成,可以解释这个现象。
如果你足够细心的话,应该能够发现,组件接收到的是props,但是在调用的时候,传入的是一个个的prop,这是怎么回事?因为props是React createElement生成的,而且是以字面量{}生成的,每次生成的引用地址都不一样。触发setState的组件会渲染,执行函数。与此对应,函数返回的jsx就是createElement调用。环环相扣,后续组件都会渲染。
memoizedState
这个属性记录了组件对应的第一个hook,后者指向下一个hook,形成一个链表。
const [state, setState] = useState
setState在返回时通过bind函数绑定了fiber,所以调用的时候能够找到对应的fiber。
alternate
对于正在渲染的fiber,alternate属性指向了上次渲染时的fiber,后者也有一个alternate属性指回。
在penddingProps与memoizedProps相等时,fiber可以跳过渲染。可是渲染是为了获得子代的信息,跳过了又怎么获得呢?答案是从alternate上获得,只要把alternate的子代信息给fiber的子代就可以实现复用。alternate属性相当于提供了缓存功能,实际上,通过alternate,React从某种意义上缓存了所有组件,无论你是否使用了相应缓存。
lanes
一个二进制数,每一位代表了一个优先级,同一个fiber上可能同时有多个优先级的更新。通过lanes可以实现调度。
每一次更新,React会指定优先级最高的lane,在处理每一个fiber时,会比对当前更新的lane与fiber的lanes,在这个fiber上只执行相匹配的lane。
stateNode
对不同type的fiber,有不同的意义:
- 原生组件对应了dom
- 复合组件为null
- 根/rootFiber对应了应用的根/root
fiber树
无论是挂载还是更新,React总是会遍历生成整棵新fiber树,这棵树最终转化为界面视图 。这并不是说凡是更新每个函数组件都会执行,相反,通过复制当前的fiber节点(与视图中的真实dom对应)的子fiber信息,可以在不执行函数的同时,获得新的子fiber。
考虑这样一个简单的应用:
function App() {
return (
<div>
2024
<p>Hello world</p>
</div>
)
}
createRoot(document.getElementById("root")).render(<App />);
它的fiber树是这样的(不包含root):
可以看到,fiber几乎与Element一一对应,实际上,不考虑rootFiber的话,fiber就是在工作中由Element生成的。
由jsx生成的Element没有对父元素的指针,一个Element也只是以props.children指向子元素数组,而fiber之间的关系则紧密得多,事实上fiber的数据结构也远远更复杂。
下面介绍fiber树的生成,也就是挂载与更新。
fiber树的生成
不管是函数组件还是类组件,只有组件经过渲染我们才能知道它的内容是什么,函数要被执行才能得到返回值,所以fiber树的形成需要一个按顺序进行的,一系列组件渲染的过程。这也是不管首次渲染还是后续更新,React都要从root遍历整个fiber树的原因。
Work
生成fiber树要调用一系列函数,核心逻辑在performUnitOfWork:
function workLoopSync() {
// Perform work without checking if we need to yield between fiber.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
一个fiber的处理工作包含了fiber对应组件的渲染,子fiber的diff,标记副作用(dom的插入,删除,更新等)等。在整棵fiber树生成后, 副作用会被执行,这是Work之外的。对于原生组件而言,副作用是dom的变化,对开发人员写的复合组件而言,就是useEffect的回调函数和生命周期钩子。
workInProgress
生成新的fiber树是在workInProgress上完成的。之前在介绍fiber的结构时,指出了fiber有atlternate这个属性。在React里,内存中一直会存在两颗fiber树:其一是与现在视图对应的(或者说上次更新的)current树,其二是正要生成的(或者说等待更新的)workInProgress树。它们各有一个atlternate属性指向对方。
两颗树的设计就是为了实现fiber的复用,或者说类似于缓存,不然生成fiber树所需要的工作代价太大。如果复用得当的话,React甚至也能有类似于细粒度更新的效果,可以有相当好的性能。当然,真正的细粒度更新不会遍历不需要更新的节点,与React的设计不同。
在不可中断更新情形下,workInProgress在工作开始时指向了root.current.atlternate,也就是workInProgress树的根节点。随着工作的进行,这个指针会指向不同的fiber。
performUnitOfWork
只保留主干逻辑的话,performUnitOfWork的内容是这样的:
function performUnitOfWork(unitOfWork): {
const current = unitOfWork.alternate;
let next = beginWork(current, unitOfWork, entangledRenderLanes);
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// 如果不会产生新的工作,就完成当前工作
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
在beginWork中会发生组件的渲染,fiber的生成,diff等等。completeUnitOfWork中会调用completeWork,切换workInProgress。completeWork主要是处理props,在挂载时还要创建dom(但还没有插入到页面中)。
从Element到Fiber
fiber是由Element生成的,而整棵fiber树的生成是一个个fiber递归生成的过程。在解释fiber时展示过一个简略图,接下来会有更详细的介绍。这种转化就如下图所示,以函数组件为例:
function Parent() {
return (
<>
<Child1 />
<Child2 />
</>
)
}
在处理Parent组件对应的fiber时,会完成子代Element到子代Fiber的转化,Parent Fiber本身也是这样生成的。当然,如果父级组件是原生组件,那么父级的Element会有props.children直接指向子Element数组,而函数组件当然要在函数执行后才能得到children。
fiber工作次序
因为fiber之间的紧密联系,React可以使用深度优先遍历的方式处理fiber树,在这种稳定的顺序下,每一个fiber都可以知道在自己工作完成后,下一个工作单元是哪个fiber,配合全局的workInProgress指针,可以在任何中断的地方恢复工作。
performUnitOfWork(workInProgress)这样的代码,容易使人联想成每一轮循环传入一个fiber,然后这个fiber处理完毕,进入下一个循环。但实际上,传入的fiber其实只是这一次工作的开始,在一轮循环中未必有fiber被处理完成,也有可能处理多个fiber。
下面展示了整个工作的次序,通过图片示例,你可以看到深度优先遍历的次序:
function App() {
return (
<>
<h1>
<p>震惊!</p>
<p>懂王胜选</p>
</h1>
2024
</>
);
}
对于任意一个节点,深度优先遍历处理的下一个节点首先是child,如果没有child,就处理sibling。
挂载
通过createRoot可以创建React的root,在root调用render方法时,带来应用的首次渲染,也就是fiber树的挂载,例如:
function App() {
const [text, greet] = useState("Hellow world!");
return <h1 onClick={() => greet("Hi!")}>{text}</h1>;
}
createRoot(document.getElementById("root")).render(<App />)
在调用createRoot(document.getElementById("root")).render(<App />)后,React做的事情包括:
- 初始化一系列React信息,创建
rootFiber,在root下,以current指向根fiberrootFiber。 - 开始一次更新,创建
workInProgress,第一个节点复用current树的rootFiber。循环执行performUnitOfWork相关逻辑,凭借<App />这样的jsx得到的Element信息依次获得整棵workInProgress树。基本上是将Element转化成包含更多信息的fiber。 - 将得到的fiber转化为界面视图。
root的current指向workInProgress树的rootFiber。
以图形的方式展现是这样的:
更新
对于上面的例子,如果我们点击了Hellow world!,那么会因为调用setState触发更新,React做的事情包括:
- 初始化
workInProgress,指向workInProgress树的rootFiber,循环执行performUnitOfWork相关逻辑生成新的fiber树,执行顺序与挂载时一致。这时workInProgressfiber与currentfiber互相以alternate属性指向对方。 - 将得到的fiber树转化为界面视图。
root的current指向workInProgress树的rootFiber。
以图形的方式展现是这样的:
图形中略过了workInProgress树的生成的中间阶段。
与挂载相比,更新的区别是不需要初始化信息,另外在生成fiber时也可以复用currentfiber的信息。这些都是就fiber而言的,当然dom也并不需要重新创建(除非更新带来了新的dom)。
对于函数组件与类组件对应的fiber,在恰当的情形,可以复用currentfiber的子代信息,使组件跳过函数执行(在上述例子里没有体现)。恰当的情形,指的是:
- 没有更新,这个组件没有setState。
- 没有context的更新。
- props引用地址没有变化。
最后一点取决于Child组件有没有形如<Child/>的jsx调用。一般而言,如果父组件渲染了,那么函数会重新返回,相应地,jsx调用了。所以触发seteState的组件以下的所有组件默认都会渲染,而上层和同层组件不会渲染。React提供了由开发者决定是否复用currentfiber的api,也就是memo。
在组件渲染后,对子代的diff过程也可以实现fiber的复用,实际上,如果组件跳过了渲染,完全不需要diff。
fiber树的特点
节点单元化
fiber树中的每一个fiber节点都可以作为独立的工作单元,这意味着整个fiber树的渲染工作可以被拆解成多个部分。这些部分既可以以同步的方式依次处理,也可以以异步的方式处理,让中断和调度React的更新成为可能。
React对每一个fiber的大体处理逻辑是相同的,而且从任何一个节点出发,都能按既有的顺序处理后续节点。
在应用并发特性时,对fiber树的处理是这样的:
function workLoopConcurrent() {
// 执行工作直到调度器通知中断
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
下面是React调度器调度更新回调函数的层级图(react-reconciler v0.29.0截至文章发布最新代码):
performWorkOnRootViaSchedulerTask在调度器中会被异步循环调用。即便workLoopConcurrent被中断,还是能够恢复执行。只要所有的workInProgress fiber被处理,无论它们是以同步还是异步的方式,对fiber树的处理工作完成。
全量更新
无论是挂载还是更新,生成一颗fiber树都需要自root遍历整棵树。即便应用了缓存,相应的节点还是被遍历到。这种全量更新是与细粒度更新相对应的。
可以说,如果没有缓存,渲染fiber树的开销是巨大的。
双缓存
在React应用的运行期间,内存中始终存在两颗fiber树:workInProgress树与current树。需要从两个层次理解这一点:
- 更新时,不会在
current树上生成新fiber树,树不是只有一颗。 - 在更新完成之后,两棵树仍然存在,因为它们各自链接向对方。
如果只是出于调度的需要,没有必要始终保留两棵树,可以仅仅是在更新的时候创建第二棵树。双缓存就是为了提升渲染性能,在组件不需要渲染的时候,复用之前的信息,跳过函数的执行。通过保留两棵树,React实际上缓存了所有组件,无论你是否应用了这些资源。
与React缓存相关的api,如memo,并未真正缓存组件,它仅仅是赋予开发者应用React自身缓存的能力。