React v15 的困境
要聊 fiber 这个东西,时间需要稍稍往前回退几年,回到 react 尚未发布 v16 的时候。如果渲染一个极为复杂的dom,如一个大数据图表,react 的表现相当不如人意。当它加载时,页面几乎动不了,用户没法进行任何操作。
这是为什么呢?让我们来看一下 react 过去的渲染原理:
- 写好 jsx
- 将 jsx render 成虚拟节点树
- 数据变更 => 视图需要发生对应变化
- 构建新虚拟节点树,并和旧节点树进行递归对照,找到他们不同的地方,然后更新他们
这个过程也称之为协调(Reconcilation)
协调期间,react 将会抢走浏览器资源,当数据量大的时候,会使其卡顿很久,无法响应其他(诸如用户点击、输入)的操作。有什么办法解决这个问题呢?
它山之石,可以攻玉
这个听起来很像操作系统课上学过的进程调度问题,我们先简单回顾一下当时有哪些算法
先来先服务:哪个程序先到,哪个程序先执行。这个是最简单的默认方案,但是如果长进程多的话,对短进程十分不公平,要等待许久。
最短作业优先:哪个程序执行的快,哪个程序先。不错,但是如果一直有短进程,长进程将永无出头之日,对长进程不公平。
高响应比优先:响应比 =(等待时间 + 执行时间)/ 执行时间,由于添加了等待时间的参数,长进程有出头之日了,还不错。
时间片轮转:这个就更高级了,在进程可中断的背景下,每个进程都给一个固定的时间片,用完了就换人,这样对大家都友好,不会出现一个程序停滞半天不动的情况。
再回到我们刚才的问题,资源全被长进程(协调)抢占了,有什么好办法呢?
是的,时间片轮转。
采用这个方案,资源会轮流交到 react 和用户手上,只要切换的够快,用户就不会感知到卡顿。
这有点类似两个顾客同时叫同一个服务员的情况,虽然他没办法分身,但是如果他以极快的速度反复横跳,飞到这说一句飞到那说一句,就可以达成同时服务两个顾客的效果。
不过时间片轮转是有个前提的:可中断
React 的破局之法
两个思路
过去 react 的协调无法中断,必须得等协调处理完毕以后,才能处理其他事务,所以 react 痛定思痛,做出了如下两个改动:
-
用更好的可中断的方式进行协调,也就是 Fiber 方案。
-
将协调拆分为协调 + 提交,从而更加精细化
浏览器的工作机制
先不谈 fiber 这个东西,我们只需要知道它可中断可恢复即可。先来想想,浏览器是否能支持中断自己的任务呢?答案是肯定的。
浏览器每一帧都要做许多事情:处理输入、处理js、回流、重绘等等。
在一帧中,当它完成了需要做的事,并且还有剩余时间时,就会调用 requestIdleCallback 的回调函数,fiber 正是利用了这个特性,将自己分片放入 requestIdleCallback 中,来实现时间片轮转。
注意:fiber只是利用了 requestIdleCallback 这个思路,但该 api 并不是对所有浏览器都支持的,所以 react 基于 MessageChannel 这个 api 来进行了实现
Fiber 是什么
fiber 被翻译为协程、纤程,它可以被分割打断。
你可以将其视为一个执行单元,每执行完一个执行单元,就会通过 requestIdleCallback 来检查还有多少剩余时间,没时间就把控制权让出给浏览器。
我们来看看 fiber 的数据结构
export type Fiber = {
type: any,
// ...
// 父节点
return: Fiber | null,
// 子节点
child: Fiber | null,
// 下一个兄弟节点
sibling: Fiber | null,
}
这是一个很清晰的链状结构,有一张不错的图来说明:
当程序处理完当前 fiber 节点时,如果还有剩余时间,那么就处理下一个子节点,如果没有,就找兄弟节点,否则就回溯到父节点。
<root>
<parent>
<child1>some1</child1>
<child2>some2</child2>
</parent>
</root>
// 详细路径
root => parent => child1 => some1 => child1 => child2 => some2 => child2 => parent => root
// 去掉重复
root => parent => child1 => some1 => child2 => some2
因为使用了链结构,所以随时可以中断和恢复。
阶段拆分
协调阶段:大致可以认为是 diff 阶段,这个阶段将把变更收集起来,但不直接更新。这个阶段是可以打断的。
提交阶段:将所有变更一次性执行,不可打断
Diff 算法
再来简单说说这个 diff 算法吧,diff 算法的目的就是找出那些要新增、删除、修改、复用的部分,以减少直接更新的开销。算法分为两个阶段:
一阶段:按顺序一一对比所有节点,如果能复用,就打上复用标签,然后对照下一个,直到遇到不可复用的为止
二阶段:将不可复用的这些 fiber 节点,放入一个map中,跟剩下的 vdom 进行对比,如果有能复用的,则打上复用标签。最后把这些剩余的 fiber 节点打上删除标签,剩余没有对应的 vdom 节点打上新增标签。
想具体了解 diff 算法的同学,建议看我的另一篇文章 别慌,react diff 没有那么复杂
总结
Fiber 其实就是一种链状数据结构的基础单元。
在过去,react 使用直接递归渲染 vdom 的方案,此方案不可中断,会有卡顿问题。
所以增加了一步:将 vdom 转化为 fiber 再进行操作,这种链式处理在收集 effect 的过程中随时可中断,最后再将这些 effect 一次性进行更新(这个commit 过程不可中断)