壹 ❀ 引
我在[react] 什么是虚拟dom?虚拟dom比操作原生dom要快吗?虚拟dom是如何转变成真实dom并渲染到页面的?一文中,介绍了虚拟dom
的概念,以及react
中虚拟dom
的使用场景。那么按照之前的约定,本文来聊聊react
中另一个非常重要的概念,也就是fiber
。那么通过阅读本文,你将了解到如下几个知识点:
react
在使用fiber
之前为什么会出现丢帧(卡顿)?- 如何理解浏览器中的帧?
- 什么是
fiber
?它解决了什么问题? fiber
有哪些优势?- 了解
requestIdleCallback
react
中的fiber
是如何运转的(fiber
的两个阶段)diff
源码分析(基于react 17.0.2)
同样,若文中涉及到的源码部分,我依然会使用17.0.2
的版本,保证文章的结论不会过于老旧;其次,fiber
的概念理解起来其实比较枯燥,但我会尽量描述的通俗易懂一点,那么本文开始。
贰 ❀ 在fiber之前
我们学习任何东西,一定会经历两个阶段,一是这个东西是什么?二是这个东西有什么用(解决了什么问题)?所以在介绍fiber
之前,我们还是先说说在fiber
之前react
遇到了什么问题,而这个问题,我们可以通过自己手写一个简单的render
来模拟react 15
之前的渲染过程。
通过虚拟dom
一文,我们已经知道所谓虚拟dom
其实就是一个包含了dom
节点类型type
,以及dom
属性props
的对象,我们假定有如下一段dom
信息,现在需要通过自定义方法render
将其渲染到页面:
const vDom = {
type: "div",
props: {
id: "0",
children: [
{
type: "span",
children: 111,
},
],
},
};
其实一共就三步,创建dom
,加工属性,以及递归处理子元素,直接上代码:
const render = (element, container) => {
// 创建dom节点
let dom = document.createElement(element.type);
// 添加属性
const props = Object.keys(element.props);
props.forEach((e) => {
if (e !== "children") {
dom[e] = element.props[e];
}
});
// 处理子元素
if (Array.isArray(element.props.children)) {
// 是数组,那就继续递归
element.props.children.forEach((c) => render(c, dom));
} else {
// 是文本节点就设置文本
dom.innerHTML = element.props.children;
}
// 将当前加工好的dom节点添加到父容器节点中
container.appendChild(dom);
};
render(vDom, document.getElementById("root"));
通过这段代码,你应该想到了一个问题,假设我们的dom
结果非常复杂,react
在递归进行渲染时一定会非常耗时;而这段代码又是同步执行,递归一旦开始就不能停止。
大家都知道浏览器中JS
线程与UI
线程互斥,假设这段代码运行的时间足够久,那么浏览器就必须一直等待,严重情况下浏览器还可能失去响应。
当然,react
团队大佬云集,不至于说react
会在渲染上严重卡顿,但在极端情况下,react
在渲染大量dom
节点时还是会出现丢帧问题,这个现象大家可以对比react 15
(栈实现)与react
引入fiber
之后的渲染差异Fiber vs Stack Demo:
很显然,在引入fiber
概念以及Reconcilation
(diff相关)重构后,react
在渲染上可以说跟德芙一样纵享丝滑了。
即便现在我们还未了解fiber
,但通过了解传统的递归渲染,我们知道了同步渲染会占用线层,既然fiber
能解决这个问题,我们可以猜测到fiber
一定会有类似线程控制的操作,不过在介绍fiber
之前,我们还是得介绍浏览器帧的概念,以及为啥react 15
会有掉帧的情况,这对于后续理解fiber
也会有一定的帮助,我们接着聊。
叁 ❀ 帧的概念
如何理解帧?很直观的解释可以借用动画制作工艺,传统的动画制作其实都是逐帧拍摄,动画作者需要将一个连贯的画面一张一张的画出来,然后再结合画面的高速切换以达到动画的效果,我相信不少人在读书时代应该也做过在课本每一页画画然后玩翻页动画的事情。
所以如果一个连贯动作我们用100个画面去呈现,那么你会发现这个画面看起来非常流畅,但如果我们抽帧到只有10帧,人物的动作就会显得不连贯且卡顿,这时候大家就说开启眨眼补帧模式。不过在视频混剪上,也有人还会故意用抽帧来达到王家卫电影的拖影效果,但这都是艺术表现层面的话术了。
所以回到浏览器渲染,我们其实也可以将浏览器的动画理解成一张张的图,而主流的显示器刷新率其实都是60帧/S,也就是一秒画面会高速的刷新60次,按照计算机1S
等于1000ms
的设定,那么一帧的预算时间其实是1000ms/60帧
也就是16.66ms
。
在实现动画效果时,我们有时候会使用到window.requestAnimationFrame
方法,关于其解释可见requestAnimationFrame MDN:
window.requestAnimationFrame()
告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。
而16.66ms
也不是我们随口一说,我们可以通过一个简单的例子来验证这个结论:
<div id="some-element-you-want-to-animate"></div>
const element = document.getElementById('some-element-you-want-to-animate');
let start;
// callback接受一个由浏览器提供的,当函数开始执行的时间timestamp
function step(timestamp) {
if (start === undefined) {
start = timestamp;
}
// 计算每一帧刷新时的类增时间
const elapsed = timestamp - start;
console.log(elapsed);
//这里使用`Math.min()`确保元素刚好停在 200px 的位置。
element.style.transform = 'translateX(' + Math.min(0.1 * elapsed, 200) + 'px)';
if (elapsed < 2000) {
// 在两秒后停止动画
window.requestAnimationFrame(step);
}
}
window.requestAnimationFrame(step);
大家有兴趣可以在本地运行下这个例子,可以看到当每一帧中执行step
方法时,所接受的开始时间的时间差都是16.66ms
。如果你的时间差要低于16.66ms
,那说明你使用的电脑显示器刷新率要高于60帧/S
。
我们人眼在舒适放松时可视帧数是24帧/S
,也就是说1S起码得得有24帧我们才会觉得画面流畅,但前文也说了,react 15
之前的版本实现,渲染任务只要过长就会一直占用线程导致浏览器渲染任务推迟,如果这个渲染之间夹杂了多次推迟,浏览器1S都不够渲染60帧甚至更低,那浏览器渲染的整体帧率自然就会下降,我们在视觉上的直观感受就是掉帧了。
那么到这里,我们解释了react 15
掉帧的根本原因,传统的递归调用栈的实现,在长任务面前会造成线程占用的情况,严重的话就会掉帧,react
急需另一种策略来解决这个问题,接下来我们就来好好聊聊fiber
。
肆 ❀ fiber是什么?
那么如何理解react
中的fiber
呢,两个层面来解释:
- 从运行机制上来解释,
fiber
是一种流程让出机制,它能让react
中的同步渲染进行中断,并将渲染的控制权让回浏览器,从而达到不阻塞浏览器渲染的目的。 - 从数据角度来解释,
fiber
能细化成一种数据结构,或者一个执行单元。
我们可以结合这两点来理解,react
会在跑完一个执行单元后检测自己还剩多少时间(这个所剩时间下文会解释),如果还有时间就继续运行,反之就终止任务并记录任务,同时将控制权还给浏览器,直到下次浏览器自身工作做完,又有了空闲时间,便再将控制权交给react
,以此反复。
传统递归,一条路走到黑
react fiber
,灵活让出控制权保证渲染与浏览器响应
而关于fiber
数据结构,我在虚拟dom
一文其实也简单提到过,每一个被创建的虚拟dom
都会被包装成一个fiber
节点,它具备如下结构:
const fiber = {
stateNode,// dom节点实例
child,// 当前节点所关联的子节点
sibling,// 当前节点所关联的兄弟节点
return// 当前节点所关联的父节点
}
这样设计的好处就是在数据层已经在不同节点的关系给描述了出来,即便某一次任务被终止,当下次恢复任务时,这种结构也利于react
恢复任务现场,知道自己接下来应该处理哪些节点。
当然,上面也抽象只是解释fiber
是个什么东西,结合react
的角度,综合来讲react
中的fiber
其实具备如下几点核心特点:
- 支持增量渲染,
fiber
将react
中的渲染任务拆分到每一帧。(不是一口气全部渲染完,走走停停,有时间就继续渲染,没时间就先暂停) - 支持暂停,终止以及恢复之前的渲染任务。(没渲染时间了就将控制权让回浏览器)
- 通过
fiber
赋予了不同任务的优先级。(让优先级高的运行,比如事件交互响应,页面渲染等,像网络请求之类的往后排) - 支持并发处理(结合第3点理解,面对可变的一堆任务,
react
始终处理最高优先级,灵活调整处理顺序,保证重要的任务都会在允许的最快时间内响应,而不是死脑筋按顺序来)
到这里,我相信大家脑中应该有了一个模糊的理解了,可能有同学就好奇了,那这个fiber
是怎么做到让出控制权的呢?react
又是怎么知道接下来自己可以执行的呢?那接下里,我们就不得不介绍另一个API
requestIdleCallback
。
伍 ❀ 关于requestIdleCallback
关于requestIdleCallback
详情大家可以查看requestIdleCallback mdn介绍,这里普及下概念:
**
window.requestIdleCallback()
**方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。
与requestAnimationFrame
类似,requestIdleCallback
也能接受一个callback
,而这个callback
又能接收一个由浏览器告知你执行剩余时间的参数IdleDeadline
,我们来看个简单的例子:
const process = (deadline) => {
// 通过deadline.timeRemaining可获取剩余时间
console.log('deadline', deadline.timeRemaining());
}
window.requestIdleCallback(process);
简单点来说,这个方法其实是浏览器在有空闲时间时会自动调用,而且浏览器会告诉你剩余时间还剩多少。
因此,我们可以将一些不太重要的,或者优先级较低的事情丢在requestIdleCallback
里面,然后判断有没有剩余时间,再决定要不要做。当有时间时我们可以去做需要做的事情,而我们决定不做时,控制权也会自然回到浏览器手里,毕竟浏览器也不会因为JS没事干而自己闲着。那么这个剩余时间是怎么算的呢?
通过上文我们知道,所谓掉帧就是,正常来说浏览器1S本来是可以渲染60帧,但由于线程一直被JS
占着,导致浏览器响应时的时间已经不够渲染这么多次了,所以整体上1S能渲染的帧数比较低,这就是我们所谓的掉帧。而一般情况下,1帧的时间是16.66ms
,那是不是表示剩余时间 = 16.66ms - (浏览器处理完自己的事情的时间)
呢?
确实是这样,但需要注意的是,在一些极端情况下,浏览器会最多给出50ms
的空闲时间给我们处理想做的事情,比如我们一些任务非常耗时,浏览器知道我们会耗时,但为了让页面呈现尽可能不要太卡顿,同时又要照顾JS
线程,所以它会主动将一帧的用时从16.66ms
提升到50ms
,也就是说此时1S浏览器至多能渲染20帧。</