前言

react 在进行组件渲染时,从 setState 开始到渲染完成整个过程是同步的(“一气呵成”)。如果需要渲染的组件比较庞大,js 执行会占据主线程时间较长,会导致页面响应度变差,使得 react 在动画、手势等应用中效果比较差。

所以在 react v15 之前使用它做一些动画效果或大数据量处理渲染之类的都可能会出现卡顿等性能问题。

react v15 是函数调用栈,递归的方式,从父节点(Virtual DOM)开始遍历,以找出不同。将所有的 Virtual DOM 遍历完成后,reconciler 才能给出当前需要修改真实 DOM 的信息,并传递给 renderer,进行渲染,然后屏幕上才会显示此次更新内容。对于特别庞大的 vDOM 树来说,reconciliation 过程会很长(大于浏览器渲染帧的时间 16ms),在这期间,主线程是被 js 占用的,因此任何交互、布局、渲染都会停止,给用户的感觉就是页面被卡住了。

破解 JavaScript 中同步操作时间过长的方法其实很简单——分片。
把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。
React Fiber 把更新过程碎片化,每执行完一段更新过程,就把控制权交还给 React 负责任务协调的模块,看看有没有其他紧急任务要做,如果没有就继续去更新,如果有紧急任务,那就去做紧急任务。
维护每一个分片的数据结构,就是 Fiber。

React Fiber

在 React Fiber 中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来。

因为一个更新过程可能被打断,所以 React Fiber 一个更新过程被分为两个阶段(Phase):第一个阶段 协调阶段 和第二阶段 提交阶段。

在第一阶段协调阶段,React Fiber 会找出需要更新哪些 DOM,这个阶段是可以被打断的;但是到了第二阶段 提交阶段,那就一鼓作气把 DOM 更新完,绝不会被打断。

这两个阶段大部分工作都是 React Fiber 做,和我们相关的也就是生命周期函数。

以 render 函数为界,第一阶段可能会调用下面这些生命周期函数,说是“可能会调用”是因为不同生命周期调用的函数不同。

第一阶段:
componentWillMount
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate

第二阶段:
componentDidMount
componentDidUpdate
componentWillUnmount

因为第一阶段的过程会被打断而且“重头再来”,就会造成意想不到的情况。

例如已经执行到某个 Fiber 内的生命周期函数 componentWillUpdate,但由于已经到了一个渲染帧的时间,需要把控制权让出,让浏览器查看是否有任务需要执行,若有则废弃当前 Fiber,执行浏览器的任务,等浏览器让出控制权后重新执行这个 Fiber,所以才会出现二次执行生命周期 componentWillUpdate 的情况。

因此第一阶段中的生命周期函数在一次加载和更新过程中可能会被多次调用!

但某些使用者在使用生命周期函数时会做一些奇怪的操作上,既违背这个生命周期提供的意义,如果出现二次调用就会出现各种 BUG,所以 react 把某些生命周期在过渡版本提示为废弃钩子,componentWillMount,componentWillReceiveProps,componentWillUpdate。

fiber 执行单元

fiber 的实现方式为时间分片 + 链表结构,一个组件实例对应有一个 fiber fiber 其实就是虚拟 Dom。

fiber 维护了一个分片的数据结构,就是可终止可恢复的链表式数据结构。

如一个<Card />组件的 fiber:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// FiberNode 结构如下:
{
// 定义fiber节点类型,类组件指向构造函数,dom元素指向标签名称
type: Card,

// Fiber类型,将React Element映射成对应的Fiber类型,用于说明协调过程中需要完成的工作
// HostRoot|HostComponent|ClassComponent|FunctionComponent...
tag: 1,

// 不同tag代表不同类型的副作用
effectTag: 1,
firstEffect: null,
lastEffect: null,

// 单链表结构,方便遍历fiber树上有副作用的节点
nextEffect: FiberNode|null,

// 第一个子fiber
child: FiberNode|null,

// 指向父fiber,表示当前节点处理完毕后,应该向谁提交自己的结果effect list
return: FiberNode|null

// 兄弟fiber
slibing: FiberNode|null,

// 当前父fiber中的位置
index: 0,

// fiber实例对象,指向当前组件实例
stateNode: Card,

// setState待更新状态,回调,DOM更新的队列
updateQueue: null,

// 当前UI的状态,反映了UI当前在屏幕上的表现状态
memoizedState: {},

// 前次渲染中用于决定UI的props
memoizedProps: {},

// 即将应用于下一次渲染更新的props
pendingProps: {},

// 和组件Element中的key,ref一致
key: null,
ref: null,

// fiber更新时基于当前fiber克隆出的镜像,更新时记录两个fiber diff的变化;更新结束后alternate替换之前的fiber成为新的fiber节点
alternate: {},

// 标记子树上待更新任务的优先级 (最新版的react做了变更,改由过期时间实现,时间越大,setState越频繁,优先级就越高)
pendingWorkPriority: number
}

通过 child, slibing, return 的结构关联,可知它是一个单链表结构。

时间分片

实现时间分片的目的只是为了在做一些大批量 DOM 渲染时,让客户端不要给客户出现卡顿的不流畅现象。

而使用浏览器提供的 API 是最好的,requestIdleCallbackrequestAnimationFrame,如果需要兼容低版本浏览器,可以使用 setTimeout 模拟,但可能会出现一瞬间的白屏情况。

setTimeout 闪屏现象

在 js 引擎里,setTimeout 的事件处理函数会先等待到目标时间后执行,然后进入事件队列里等待主线程的空闲,因此 setTimeout 的实际执行时间可能会比其设定的时间晚一些。

那么我们在 setTimeout 设置的时间与浏览器的渲染时间很大可能是不一致的,而且很大可能是 setTimeout 被主线程处理比渲染要晚一些,导致 setTimeout 里的渲染内容只能在下一帧渲染时被渲染,这是丢帧现象。

例子

  1. 没有用时间分片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 记录任务开始时间
let now = Date.now();
// 插入十万条数据
const total = 100000;
// 获取容器
let ul = document.getElementById("app");
// 将数据插入容器中
for (let i = 0; i < total; i++) {
let li = document.createElement("li");
li.innerText = ~~(Math.random() * total);
ul.appendChild(li);
}

console.log("JS运行时间:", Date.now() - now);
setTimeout(() => {
console.log("总运行时间:", Date.now() - now);
}, 0);

十万条数据渲染时会出现很明显的卡顿白屏等现象。

  1. requestAnimationFrame 实现时间分片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//需要插入的容器
let ul = document.getElementById("app");
// 插入十万条数据
let total = 100000;
// 一次插入 20 条
let once = 20;
//总页数
let page = total / once;
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal, curIndex) {
if (curTotal <= 0) {
return false;
}
//每页多少条
let pageCount = Math.min(curTotal, once);
window.requestAnimationFrame(function () {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement("li");
li.innerText = curIndex + i + " : " + ~~(Math.random() * total);
ul.appendChild(li);
}
loop(curTotal - pageCount, curIndex + pageCount);
});
}
loop(total, index);

在操作时很顺畅,体验很好。

宝藏资料

这可能是最通俗的 React Fiber(时间分片) 打开方式
React Fiber
React Fiber 是什么
fiber
React Fiber 初探