深入理解React Fiber
我们知道ReactDOM在后台构建DOM树并将应用程序渲染在屏幕上。但是React实际上是如何构建DOM树的?当应用程序状态更改时,它如何更新树?
在本文中,我将首先说明React在React 15.0.0及之前是如何构建DOM树的,该模型的缺陷以及React 16.0.0的新模型如何解决这些问题。这篇文章将涵盖广泛的概念,这些概念纯粹是内部实现细节,对于使用React进行实际的前端开发并不是严格必需的。
React15架构
React15架构可以分为两层:
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
Renderer(渲染器)
由于React
支持跨平台,所以不同平台有不同的Renderer。我们前端最熟悉的是负责在浏览器环境渲染的Renderer —— ReactDOM。
除此之外,还有:
- ReactNative渲染器,渲染App原生组件
- ReactTest渲染器,渲染出纯Js对象用于测试
- ReactArt渲染器,渲染到Canvas, SVG 或 VML (IE8)
在每次更新发生时,Renderer接到Reconciler通知,将变化的组件渲染在当前宿主环境。
Reconciler(协调器)
我们知道,在React
中可以通过this.setState
、this.forceUpdate
、ReactDOM.render
等API触发更新。
每当有更新发生时,Reconciler会做如下工作:
- 调用函数组件、或class组件的
render
方法,将返回的JSX转化为虚拟DOM - 将虚拟DOM和上次更新时的虚拟DOM对比
- 通过对比找出本次更新中变化的虚拟DOM
- 通知Renderer将变化的虚拟DOM渲染到页面上
例子
当React遇到一个类或函数组件时,它会问该元素根据其属性将渲染成什么元素。例如
如果 <App/>
组件渲染下面的内容
1 |
|
React将根据其相应的属性询问<Form>
和<Button>
组件它们要渲染成什么。例如,如果该Form
组件是如下所示的函数组件:
1 |
|
React将调用 render
方法以知道它渲染了哪些元素,并最终将看到它渲染了 <div>
并带有一个子元素。React将重复此过程,直到知道页面上每个组件的基础DOM标签元素为止。
为了知道react应用组件树的基础DOM标签元素,递归遍历一个树的过程被称作 reconciliation
在reconciliation
结束时,React知道了DOM树的结果,并且像react-dom或react-native这样的渲染器将应用所需的最小更改集进行更新DOM节点。
因此,这意味着当您调用 ReactDOM.render()
或 setState
时,React执行了reconciliation
。在 setState
的情况下,它将执行遍历并通过将新树与渲染的树进行比对来找出树中发生了什么变化。然后,将那些更改应用于当前树,从而更新与调用setState
相对应的状态。
现在我们知道了什么是reconciliation
让我们来看看,这种模型有什么缺陷。
帧频
帧频是连续图像出现在显示器上的频率。我们在计算机屏幕上看到的所有内容都是由屏幕上播放的图像或帧组成,这些图像或帧的显示速率瞬间达到了眼睛。
要理解这是什么意思,可以将计算机显示屏看作一本翻页书,而将翻页书的页视为翻页时以一定速率播放的帧。换句话说,计算机显示器不过是一本自动翻页书,当屏幕上的事物发生变化时,它会一直播放。
通常,为了使视频对人眼感觉平滑且瞬间,视频需要以每秒30帧(FPS)的速率播放。高于此值将提供更好的体验。这就是为什么游戏玩家喜欢第一人称射击游戏时更高的帧频的主要原因之一,而精确度非常重要。
话虽这么说,如今大多数设备以60 FPS刷新屏幕-换句话说,就是1/60 = 16.67ms,这意味着每16ms就会显示一个新帧。这个数字非常重要,因为如果React渲染器花费16毫秒以上的时间在屏幕上渲染某些东西,浏览器将丢弃该帧。
但是,实际上,浏览器还要执行其他工作,因此您的所有工作都需要在10毫秒内完成。如果您无法满足此预算,则帧速率会下降,并且屏幕上会显示内容抖动。这通常称为卡顿,会影响用户体验。
究其原因,JS可以操作DOM,GUI渲染线程与JS线程是互斥的。所以JS脚本执行和浏览器布局、绘制不能同时执行。
在每16.6ms时间内,需要完成如下工作:
JS脚本执行 —– 样式布局 —– 样式绘制
当JS执行时间过长,超出了16.6ms,这次刷新就没有时间执行样式布局和样式绘制了。
假设这样一个画面
在组件过于庞大,js线程花费大量时间reconciliation,无法在恰当时间内把控制权交给浏览器,进行gui渲染
递归更新的缺点
为了理解为什么会发生这种情况,让我们举一个简单的例子,看看调用栈中发生了什么。
1 |
|
如我们所见,调用栈将每个对 fib()
调用推入栈,直到弹 fib(1)
出为止,这是要返回的第一个函数调用。然后,它继续推送递归调用,并在到达return语句时再次弹出。这样,它有效地使用了调用栈,直到 fib(3)
返回并成为从栈中弹出的最后一项。
由于递归执行,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。
那么React15的架构支持异步更新么?让我们看一个例子:
初始化时state.count = 1
,每次点击按钮state.count++
列表中3个元素的值分别为1,2,3乘以state.count
的结果
:::
我用红色标注了更新的步骤。
我们可以看到,Reconciler和Renderer是交替工作的,当第一个li
在页面上已经变化后,第二个li
再进入Reconciler。
由于整个过程都是同步的,所以在用户看来所有DOM是同时更新的。
接下来,让我们模拟一下,如果中途中断更新会怎么样?
以下是我们模拟中断的情况,实际上React15
并不会中断进行中的更新
当第一个li
完成更新时中断更新,即步骤3完成后中断更新,此时后面的步骤都还未执行。
用户本来期望123
变为246
。实际却看见更新不完全的DOM!(即223
)
基于这个原因,React
决定重写整个架构。
Fiber Reconciler 的工作原理
fiber Reconciler 是如何解决上面的问题
答案是在浏览器每一帧的时间中,预留一些时间给JS线程,React利用这部分时间更新组件(在源码中,预留的初始时间是5ms)。
当预留的时间不够用时,React将线程控制权交还给浏览器使其有时间渲染UI,React则等待下一帧时间到来继续被中断的工作。
这种将长任务分拆到每一帧中,像蚂蚁搬家一样一次执行一小段任务的操作,被称为时间切片(time slice)
那么如何实现这种调度的呢?
Scheduler(调度器)
既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。
其实部分浏览器已经实现了这个API,这就是requestIdleCallback。但是由于以下因素,React放弃使用:
- 浏览器兼容性
- 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的requestIdleCallback触发的频率会变得很低
基于以上原因,React实现了功能更完备的requestIdleCallbackpolyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优- 先级供任务设置。
我们这里可以姑且用requestIdleCallback来理解
Fiber节点
我们主要可以关注一几个属性
Child
代表我们在组件中调用 render
或者直接 return
的元素,比如:
1 |
|
<Name/>
组件的 Child
是 div
,因为它返回 <div/>
元素
Sibling
表示render
返回元素列表的情况。
1 |
|
在上述情况下,<Customdiv1>
和<Customdiv2>
是 <Name>
的child。这两个Child形成一个单链表。
Return
表示返回栈帧,从逻辑上讲,它是返回到父Fiber节点的返回。因此,它代表父代。
Alternate
在任何时候,一个组件实例最多具有两个与其对应的 Fiber
:当前 Fiber
和工作中的 Fiber
。当前 Fiber
的alternate属性指向工作中的 Fiber
,而工作中的 Fiber
alternate属性指向当前 Fiber
。当前的 Fiber
表示已经渲染的内容,而从概念上讲,工作中的 Fiber
是尚未返回的栈帧。
stateNode
Fiber对应的真实DOM节点
render阶段
“递”阶段
首先从rootFiber开始向下深度优先遍历。为遍历到的每个Fiber节点调用beginWork方法 。
该方法会根据传入的Fiber节点创建子Fiber节点,并将这两个Fiber节点连接起来。
组件更新时会使用diff算法,为生成的Fiber节点带上effectTag属性,标记是否增,删,还是改
当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段。
“归”阶段
在“归”阶段会调用completeWork处理Fiber节点。
当某个Fiber节点执行完completeWork,如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。
如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段。
同样在这个阶段,如果组件是第一次加载,那么会在filber
结点里构建对应dom
结点,并在归时把子结点append
到父节点,如果是更新就处理props
,并将effectTag
用单链表串联成effectList
“递”和“归”阶段会交错执行直到“归”到rootFiber。至此,render阶段的工作就结束了。
例子
1 |
|
我们可以看到,Fiber树由单链表形式相互链接的同级关系和父子关系的子节点组成。可以使用深度优先搜索遍历该树。
在我们点击平方按钮时
询问主线程
主线程答复
进行workloop 到hostroot
diff 未改变 就拷贝
进行workloop 到list
依次类推 改变的打上标签
但是这时候剩余的时间已经不够了
这时候我们已经存下了变更
当主线程处理完了其他事情
上述过程我们称为Schedule/render阶段 ,此过程是可中断的,但是进入commit阶段就无法中断
commit阶段
时间充裕进入commit阶段
此时 effctList链表如图
然后遍历链表 完成更新
将current指向最新的tree
commit阶段流程图
优先级
假设在我们 上述第一个Schedule/render阶段之后 ,点击了紧急按钮会发生什么事
按照优先级顺序,这个紧急事件会进行插队
直到这个处理完了,才会去处理剩下的<Item/>
如果有很多优先级高的情况呢
我们可以考虑一下,当这种情况出现时,势必会导致低优先级的任务一直被阻塞,无法执行,我们称作低优先级饿死
react官方通过expirationTime属性来解决这个问题
每一个fiber都分配一个expirationTime属性(其实有多种expirationTime属性),它是大于当时的毫秒数。但调度器执行时,就计算出当前的毫秒数now, 然后now - fiber.expirationTime >= 0,那么这fiber就可以更新了,其priorityLevel会改成ImmediatePriority——司徒正美
react17 中的启发式更新算法
expirationTimes模型可以轻松胜任cpu操作,但是不能满足IO操作,会导致高优先级的IO操作会阻断低优先级的cpu操作,即使是某一个组件里的io操作都会阻断整个fiber树的其他cpu操作
所以使用lanes模型来区分IO和cpu操作