时间切片
假如React
一个更新需要耗时200ms
,我们可以将其拆分为40
个5ms
的更新(后续会讲到如何拆分),然后每一帧里只花5ms
来执行更新。那么,每一帧里不就剩余16.7 - 5 = 11.7ms
的时间可以进行用户事件
,渲染
等其他的js
操作吗?如下所示:
那么这里就有两个问题:
- 问题1:如何控制每一帧只执行
5ms
的更新? - 问题2:如何控制
40
个更新分配到每一帧里?
对于问题1比较容易,我们可以在更新开始时记录startTime
,然后每执行一小段时间判断是否超过5ms
。如果超过了5ms
就不再执行,等下一帧再继续执行。
对于问题2,我们可以通过宏任务实现。比如5ms
的更新结束了,那么我们可以为下一个5ms
更新开启一个宏任务。浏览器则会将这个宏任务分配到当前帧或者是下一帧执行。
注意:
浏览器这一行为是内置的,比如设置 10000 个 setTimeout(fn, 0),并不会阻塞线程,而是浏览器会将这 10000 个回调合理分配到每一帧当中去执行。
比如:10000个个 setTimeout(fn, 0)在执行时,第一帧里可能执行了300个 setTimeout 回调,第二帧里可能执行了400个 setTimeout 回调,第 n 帧里可能执行了 200 个回调。浏览器为了尽量保证不掉帧,会合理将这些宏任务分配到帧当中去。
解决了上面两个问题,那么这个时候我们就有下面这种思路了:
- 更新开始,记录开始时间
startTime
。 js
代码执行时,记录距离开始时间startTime
是否超过了5ms
。- 如果超过了
5ms
,那么这个时候就不应该再以同步的形式来执行代码了,否则依然会阻塞后续的代码执行。 - 所以这个时候我们需要把后续的更新改为一个宏任务,这样浏览器就会分配给他执行的时机。如果有用户事件进来,那么会执行用户事件,等用户事件执行完成后,再继续执行宏任务中的更新。
如上图所示,由于更新拆分成了一个个小的宏任务,从而使得click
事件的回调有机会执行。
现在我们已经解决了更新阻塞的问题,接下来就需要解决如何将一个完整的更新拆分为多个更新,并且让它可以暂停等到click
事件完成后再回来更新。
Fiber 架构
React
传统的Reconciler
是通过类似于虚拟DOM
的方式来进行对比和标记更新。而虚拟DOM
的结构不能很好满足将更新拆分的需求。因为它一旦暂停对比过程,下次更新时,很难找到上一个节点和下一个节点的信息,虽然有办法能找到,但是相对而言比较麻烦。所以,React
团队引入了Fiber
来解决这一问题。
每一个DOM
节点对应一个Fiber
对象,DOM
树对应的Fiber
结构如下:
(图片来自于这里)
Fiber
通过链表
的形式来记录节点之间的关系,它与传统的虚拟DOM
最大的区别是多加了几个属性:
return
表示父节点fiber
。child
表示子节点的第一个fiber
。sibling
表示下一个兄弟节点的fiber
。
通过这种链表的形式,可以很轻松的找到每一个节点的下一个节点或上一个节点。那么这个特性有什么作用呢?
结合上面提到的时间切片的思路,我们需要判断更新是否超过了5ms
,我们以上面这棵Fiber
树梳理一下更新的思路。从App Fiber
开始:
- 浏览器第一帧:
- 记录更新开始时间
startTime
。 - 首先计算
App
节点,计算完成时,发现更新未超过5ms
,继续更新下一个节点。 - 计算
div
节点,计算完成时,发现更新超过了5ms
,那么不会进行更新,而是开启一个宏任务。
- 记录更新开始时间
- 浏览器第二帧:
- 上一帧最后更新的是div节点 ,找到下一个节点
i am
,计算该节点,发现更新未超过5ms
,继续更新下一个节点。 - 计算
span
节点,发现更新超过了5ms
,那么不会进行更新,而是开启一个宏任务。
- 上一帧最后更新的是div节点 ,找到下一个节点
- 浏览器第三帧:
- 上一帧最后更新的是span节点 ,找到下一个节点
KaSong
,计算该节点,更新完成。
- 上一帧最后更新的是span节点 ,找到下一个节点
注:
- 实际的更新过程是 beginWork / completeWork 递与归的阶段,与这里有出入,这里仅做演示介绍。
- 这里的更新过程有可能不是第二帧和第三帧,而是在一帧里执行完成,具体需要看浏览器如何去分配宏任务。
- 更新过程分为 reconciler 和 commit 阶段,这里只会将 reconciler 阶段拆分。而 commit 阶段是映射为真实 DOM,无法拆分。
对应浏览器中的执行过程如下:
在这个过程中,每个节点计算完成后都会去校验更新时间是否超过了5ms
,然后找到下一个节点继续计算,而双向链表恰恰是切合这种需求。
小结
通过上面的分析,我们可以总结成以下思路:
- 更新时遍历更新每一个节点,每更新一个
Fiber节点
后,会判断累计更新时间是否超过5ms
。 - 如果超过
5ms
,将下一个更新创建为一个宏任务,浏览器自动为其分配执行时机,从而不阻塞用户事件等操作。 - 如果更新的过程中,用户进行触发了点击事件,那么会在
5ms
与下一个5ms
的间隙中去执行click
事件回调。
通过以上步骤,我们能够将现有的同步更新转变为多个小更新分配到浏览器帧里,并且不会阻塞用户事件 。接下来看看在React
中实际是如何做到的。