Que's Blog

WebFrontEnd Development

0%

v16的开始 || react-Fiber

一段废话

我当我把v15.6.2大体的东西读得稍有心得的时候(当时选定React版本时其正陷于许可证争论),此时版本迭代已经到了16.8.6。

不得不感慨前端库迭代的速度,这一年间,React Fiber 和 React Hook特性正式发布了。工程化上React也放弃了Gulp构建转而换为了Rollup。

读完的时候很多东西已经落时,但是当我写到这里的时候心里也并没有后悔。把React大体的读一遍,一则算是个人对峰顶的一次仰望,二则也算是圆了自己充舒适区走出来的想法,三来React再更新,最核心的地方也算是没有白理解。

感慨完毕之后,这里主要是准备换地图继续看新特性了。目标版本是16.8.6

在15.6.2的源码阅读过程中,学会了很多很多深入理解的小技巧,迷茫时候也曾参考别人的文章,无处参考时候也无数次硬啃过各个难点。回想之前种种,阅读源码这事,真的有些如鱼饮水冷暖自知。

故这里小小记录,以为记。

​ ——(by 2019.7.15)

前言

这一小节想想了还是列一下想从v16中了解的部分。

  • React新的工程架构
  • React Fiber
  • render 可以返回字符串,数组,数字
  • Error Boundary
  • 新的生命周期
  • React Portal
  • Fragement
  • React Hook

这里最想知道的还是React的Fiber部分,在React15上实际已经有了相关代码,但是没有正式发布。

所以这一篇暂时从Fiber开始。

Fiber的动机

虚拟DOM的理念在设计上个人认为已经接近理想性能的极致了,但是后来它还是遇到了现实意义上的瓶颈——当应用越来越大,卡顿现象也就会多起来。

之前我们认真研读了React15.6.2的reconciler源码,以及Diff算法。基于这些理解,可以很容易明白在React15的机制中,这个相关的调和过程,实际是树状、一路递归到子节点的。虽然Diff算法已经设计得非常高效,但是依然会面对递归大型的树时候,因为密集的计算导致主线程堵塞,进而对动画相关处理无法响应,造成了卡顿。

除此以外,当应用响应用户交互时候,绑定的事件过多,基于V15版本关于这块回调是归总并顺序同步执行,如果事件回调耗时过久,导致后来页面触发的更新渲染过慢,如此再三累计起来,卡顿现象就会更为明显。

想要改变这个现状,就必须在数据结构上有所创新——树结构无法预测它后面会有多少层、每层也无法预知会有多少子节点。所以Fiber采用了扁平化的数据结构,这样操作长度就可以被预知,卡顿的现象就有了处理的余地。

概念上这块这里就不再继续说了,因为这里不想画图,所以给大家一个别人写的浅析

这里提一下概念上的东西,v15的reconciler叫做stack reconciler,v16的reconciler叫做fiber reconciler。

Fiber的拆分

在React15中,调和的过程实质上是组件的Diff,最后批量进行patch的过程。

这个Diff是可以中间停下来的,而patch批量处理批量处理,当然也是可以停下的,但是这不会有什么实质意义。

就上一小节的Fiber点动机来说,V15中Diff是对一棵树的一个不可预见、不能停止的遍历、递归的diff,它如果面临了过于庞大的树,这个Diff就会长时间占用主进程,页面就会卡,所以对它的拆分,可能是最好的选择。

其次就是patch一次执行太多事情,可能也会导致丢帧,这里也应该是通过上层diff的拆分来是实现。

stack reconciler和fiber reconciler本质的区别在于,fiber reconciler不再像stack reconciler那样直接以树结构遍历实例树,而是改用链表的方式来实现它们。这个从树到链表的变化,是Fiber的本质改进

说实话,将树改成链表,这实际上是一个有点反人类的做法,不过它确实能解决性能上的问题——树的递归是无法中途暂停的(因为上层递归操作依赖下级递归操作的返回值),但是链表可以。

但是不管是stack reconciler还是fiber reconciler,不变的是,依然是对组件树的遍历的实现。

ps: 如果这块大家理解上有困难,那么可能需要对浏览器的宏任务和微任务有基础认识。我们在react里面的各种diff操作以及事件的集中执行这些都是微任务的范畴,而浏览器的刷新则属于宏任务的范畴。浏览器每次执行宏任务之前都会确保所有的微任务被全部执行完毕。这块知识属于EventLoop的范畴。

Fiber的遍历

Fiber的遍历是异步的遍历。这个遍历其实非常有意思,这让我想起《javascript忍者秘籍第二版》里面提到的,使用generator函数来进行DOM树的遍历。这块的代码部分主要从书上直接抄过来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script>  
function traverseDOM(element, callback) {   
callback(element);   ⇽--- 用回调函数处理当前节点   
element = element.firstElementChild;   
while (element) {    
traverseDOM(element, callback);    
element = element.nextElementSibling;   
}  ⇽--- 遍历每个子树  
}  
const subTree = document.getElementById("subTree");  
traverseDOM(subTree, function(element) {   
assert(element !== null, element.nodeName);  
});  ⇽--- 通过调用traverseDOM方法从根节点开始遍历
</script>

上面的代码是一个简单的,同步递归。本质上,也就是stack reconciler采用的办法。

如果换成生成器是这样:

1
2
3
4
5
6
7
8
9
10
11
function* DomTraversal(element){  
yield element;  
element = element.firstElementChild;  
while (element) {   
yield* DomTraversal(element); 
element = element.nextElementSibling;  
}
}
for(let element of DomTraversal(subTree)) {  
assert(element !== null, element.nodeName);
}

这中间有什么区别呢?区别在于,for…of这个函数的实现。或者说再往根源上说,在于迭代器的本质。

迭代器会有next方法,当我们执行DomTraversal.next()时候,返回值的类型是{done: boolean; value: any}这样的结构。直到到达终点,这个done===true。

而for…of则是对迭代器的遍历。它相当于:

1
2
3
while(!(let item = weaponsIterator.next()).done) {
// do something
}

所以在这个异步的流程中,我们可以随自己心意,随时随地去执行next(),而不需要像同步代码那样,必须一口气,处理到底。

这中间,可以斡旋的余地,就完全可以满足Fiber这种需要了。

以上是原理性说明。并不是实际实现办法,仅作思路引导。实际上React内部的Fiber节点并不是利用这种方法实现。具体实现可以看看后面的文章。

Fiber节点

数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
stateNode: new Bar, // 组件的类实例、DOM 节点或与 Fiber<ReactNode>
type: Bar | HTMLDivElement, // 定义此 Fiber 节点的函数或类
alternate: null, // 这是当前fiberNode节点的副本
key: null, // 唯一标识符 Tree Diff用得上
updateQueue: null, // state更新队列和回调相关
memoizedState: any, // PrevComponent State
pendingProps: {}, // NextComponent Props
memoizedProps: {}, // PrevComponent Props
tag: 1, // Fiber 的类型
effectTag: 0, // Enum Effect 副作用类型标记 这里和之前Diff实现中标记有部分吻合
nextEffect: null, // 链表 指向下一个具有Side-Effect的Fiber节点
return: Fiber | null, // 指向虚拟DOM上一级节点
child: Fiber | null, // 相当于虚拟DOM树上该节点的firstElementChild
sibling: Fiber | null,// 下一个子节点
}

tag类型相关可以参见这里。

基于Fiber的Diff算法-理论篇

在说Fiber的链表Diff实现之前,还是可以考虑一下v16版本之前的基于Tree的Diff算法。

在基于树的对比策略下,v15版本采用的更新策略是: 根据更新逻辑, 从VDOM上确定一个尽可能小范围但是包含所有更新的节点作为对比根节点,然后从根到child节点遍历进行组件对比、Tree对比、Element对比,完成之后执行Patch。关于这个环节更具体的细节可以参考之前的文章。

那么在Fiber下是怎样的操作呢?因为这里不再以Tree作为对比的数据结构,所以之前的Diff实现需要有新的实现(感觉思路上其实倒是类似)。

因为基于链表的设置,这里Diff可以不用尽可能的将对比根节点往下面层级缩小,每次的对比都可以直接从根节点开始,而且因为基于对链表的设计,没什么损耗。

Fiber和ReactElement

不管如何,当标记更新之后,还是要执行Patch处理,只有这样才会将计算结果反馈到UI,对比才有现实意义。

所以理解Fiber和ReactElement的对应是一个很重要的环节。

同时,他也是衔接v15和v16源码理解的重要线索。

这里它们之间关键联结在FiberNode三个属性上。当渲染的render阶段结束,commit阶段开始

1
2
3
memoizedProps: 保存旧节点的ReactElement
pendingProps: 保存新节点的ReactElement
stateNode: 保存新节点的ReactElement对应的DOM(当然这只是特定场景下的)

关于这个属性,还得牵扯到updateQueue。