我们先看一个例子:
https://claudiopro.github.io/react-fiber-vs-stack-demo/stack.html
在上面这张图片中,页面出现一卡一卡的现象。
当浏览器每秒刷新的次数低于60hz人眼就会感知卡顿掉帧等情况。
浏览器每秒刷新的次数称为 FPS(frame per second)。
浏览器的一帧说的就是一次完整的重绘。
理论上FPS越高人眼觉得界面越流畅,在两次屏幕硬件刷新之间,浏览器正好进行一次刷新(重绘),网页也会很流畅,当然这种是理想模式, 如果两次硬件刷新之间浏览器重绘多次是没意义的,只会消耗资源,如果浏览器重绘一次的时间是硬件多次刷新的时间,那么人眼将感知卡顿掉帧等, 所以浏览器对一次重绘的渲染工作需要在16ms(1000ms/60)之内完成,也就是说每一次重绘小于16ms才不会卡顿掉帧。
实际上,对用户来说,不良的体验不只是视觉上表现为卡顿与掉帧,因为在浏览器中,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。通常这时,对于用户在输入框输入内容这个行为来说,就体现为按下了键盘按键但是页面上不实时显示输入。
用户的交互得不到及时的响应,这对网页的用户体验来说是非常不利的。
每当有更新发生时,Reconciler会做如下工作:
在每16.6ms时间内,需要完成如下工作:
JS脚本执行 ----- 样式布局 ----- 样式绘制
对于React的更新来说,递归遍历应用的所有节点由于递归执行,计算出差异,然后再更新 UI。递归是不能被打断的,所以更新一旦开始,中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。
另一方面,递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,当层级很多的,可能会出现爆栈(stack overflow)的错误。当然这是递归的另一个缺点,但并不是React要优化的主要原因。
既然递归的不可打断的计算更新阻塞了用户的交互,有没有可能做到这样的效果:当浏览器有空闲时间就正常进行计算更新,而当有其他优先级更高的事件需要响应时就先暂停计算,去响应优先级高的事件,响应结束之后再接着刚才的计算继续进行,直到结束。
React 16实现的思路是这样的:将运算切割为多个步骤,分批完成。说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。这就是React 16中的Fiber设计思想。
有了解题思路后,我们再来看看 React 16具体是怎么做的。
为了加以区分,以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:
而 Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行:
为了达到这种效果,就需要有一个调度器 (Scheduler) 来进行任务分配。任务的优先级有六种:
Fiber Reconciler 在执行过程中,会分为 2 个阶段。
看一下React 16下的效果
https://claudiopro.github.io/react-fiber-vs-stack-demo/fiber.html
可以看到,动画变得顺滑很多。
接下来看下Fiber的具体细节。
Fiber Reconciler 在阶段一进行 Diff 计算的时候,会生成一棵 Fiber 树。这棵树是在 Virtual DOM 树的基础上增加额外的信息来生成的,它本质来说是一个链表。
每个Fiber节点有个对应的React element,多个Fiber节点是如何连接形成树呢?靠如下三个属性:
// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;
举个例子,如下的组件结构:
function App() {
return (
<div>
i am
<span>KaSong</span>
</div>
)
}
作为一种静态的数据结构,保存了组件相关的信息:
// Fiber对应组件的类型 Function/Class/Host...
this.tag = tag;
// key属性
this.key = key;
// 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.type = null;
// Fiber对应的真实DOM节点
this.stateNode = null;
作为动态的工作单元,Fiber中如下参数保存了本次更新相关的信息。
// 保存本次更新造成的状态改变相关信息
this.pendingProps = pendingProps;
this.memoizedProps = null;
this.updateQueue = null;
this.memoizedState = null;
this.dependencies = null;
this.mode = mode;
// 保存本次更新会造成的DOM操作
this.effectTag = NoEffect;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
Fiber 树在首次渲染的时候会一次过生成。在后续需要 Diff 的时候,会根据已有树和最新 Virtual DOM 的信息,生成一棵新的树。这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程。
如果过程中有优先级更高的任务需要进行,则 Fiber Reconciler 会丢弃正在生成的树,在空闲的时候再重新执行一遍。
在构造 Fiber 树的过程中,Fiber Reconciler 会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。
在内存中构建并直接替换的技术叫做双缓存。
React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。
在React中最多会同时存在两棵Fiber树。当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树。
current Fiber树中的Fiber节点被称为current fiber,workInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React应用的根节点通过current指针在不同Fiber树的rootFiber间切换来实现Fiber树的切换。
当workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树。
每次状态更新都会产生新的workInProgress Fiber树,通过current与workInProgress的替换,完成DOM更新。
window.requestIdleCallback()
方法将在浏览器的空闲时段内调用的函数排队。
场景一
当浏览器一帧渲染所用时间小于屏幕刷新率(对于具有60Hz 的设备,一帧间隔应该小于16ms)时间,到下一帧渲染渲染开始时出现的空闲时间,如图idle period,
场景二
当浏览器没有可渲染的任务,主线程一直处于空闲状态,事件队列为空。为了避免在不可预测的任务(例如用户输入的处理)中引起用户可察觉的延迟,这些空闲周期的长度应限制为最大值50ms,也就是timeRemaining最大不超过50(也就是20fps,这也是react polyfill的原因之一),当空闲时段结束时,可以调度另一个空闲时段,如果它保持空闲,那么空闲时段将更长,后台任务可以在更长时间段内发生。如图:
注意:timeRemaining最大为50毫秒,是根据研究 [RESPONSETIME ] 得出的,该研究表明,对用户输入的100毫秒以内的响应通常被认为对人类是瞬时的,就是人类不会有察觉。将闲置截止期限设置为50ms意味着即使在闲置任务开始后立即发生用户输入,用户代理仍然有剩余的50ms可以在其中响应用户输入而不会产生用户可察觉的滞后。
requestIdleCallback
用法:
var handle = window.requestIdleCallback(callback[, options])
callback: 一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。
其中 IdleDeadline 对象包含:
didTimeout,布尔值,表示任务是否超时,结合 timeRemaining 使用。
timeRemaining(),表示当前帧剩余的时间,也可理解为留给任务的时间还有多少。
options的参数
timeout: 表示超过这个时间后,如果任务还没执行,则强制执行,不必等待空闲。尚未通过超时毫秒数调用回调,那么回调会在下一次空闲时期被强制执行。如果明确在某段时间内执行回调,可以设置timeout值。在浏览器繁忙的时候,requestIdleCallback超时执行就和setTimeout效果一样。
对于用户响应的行为,50ms的timeRemaining是能够做到使用户感觉瞬时的,但是对于人眼能够感知到的卡顿,需要FPS至少为60帧/秒,即每次最多16.67ms,这一点timeRemaining 无法做到,所以React在此之上实现了自己的pollyfill。
源码在packages/scheduler/src/forks/SchedulerHostConfig.default.js下,分别对非DOM和DOM环境有不同的实现。
export let requestHostCallback; // 类似requestIdleCallback
export let cancelHostCallback; // 类似cancelIdleCallback
export let requestHostTimeout; // 非dom环境的实现
export let cancelHostTimeout; // 取消requestHostTimeout
export let shouldYieldToHost; // 判断任务是否超时,需要被打断
export let requestPaint; //
export let getCurrentTime; // 获取当前时间
export let forceFrameRate; // 根据fps计算帧时间
// 非dom环境
if (typeof window === 'undefined' || typeof MessageChannel !== 'function') {
let _callback = null; // 正在执行的回调
let _timeoutID = null;
const _flushCallback = function() {
// 如果回调存在则执行,
if (_callback !== null) {
try {
const currentTime = getCurrentTime();
const hasRemainingTime = true;
// hasRemainingTime 类似deadline.didTimeout
_callback(hasRemainingTime, currentTime);
_callback = null;
} catch (e) {
setTimeout(_flushCallback, 0);
throw e;
}
}
};
// ...
requestHostCallback = function(cb) {
// 若_callback存在,表示当下有任务再继续,
if (_callback !== null) {
// setTimeout的第三个参数可以延后执行任务。
setTimeout(requestHostCallback, 0, cb);
} else {
// 否则直接执行。
_callback = cb;
setTimeout(_flushCallback, 0);
}
};
cancelHostCallback = function() {
_callback = null;
};
requestHostTimeout = function(cb, ms) {
_timeoutID = setTimeout(cb, ms);
};
cancelHostTimeout = function() {
clearTimeout(_timeoutID);
};
shouldYieldToHost = function() {
return false;
};
requestPaint = forceFrameRate = function() {};
} else {
// 一大堆的浏览器方法的判断,有performance, requestAnimationFrame, cancelAnimationFrame
// ...
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// yieldInterval每帧的时间,deadline为最终期限时间
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime,
);
if (!hasMoreWork) {
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
// 如果有更多的工作,就把下一个消息事件安排在前一个消息事件的最后
port.postMessage(null);
}
} catch (error) {
// 如果调度任务抛出,则退出当前浏览器任务,以便观察错误。
port.postMessage(null);
throw error;
}
} else {
isMessageLoopRunning = false;
}
needsPaint = false;
};
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
};
}
总结:非DOM模式下requestHostCallback是setTimeout模拟实现的,而在DOM下是基于MessageChannel消息的发布订阅模式postMessage和onmessage实现的。
熟悉requestidlecallback到了解react ric polyfill实现
React理念
React Fiber原理介绍