ReactFiber原理剖析

众所周知(其实只有程序员知道),react在更新大版本v16之后,整体架构几乎重新设计,生命周期大改,函数式组件的使用,Hook的引入等等,其中比较重要的一个重头戏就是fiber架构的引入,从一定程度上来说,fiber架构影响了react原先的生命周期。

React Fiber的提出无非依然是解决性能相关的问题,那么接下来就来探讨一下react通过这个架构,对性能优化所做的努力

中断耗时长的任务

众所周知(依然只有程序员知道)javascript单线程运行的,而且在浏览器环境要干的活非常多,它要负责页面的JS解析和执行、绘制、事件处理、静态资源加载和处理。

它只是一个’JavaScript’,同时只能执行一个任务,事情只能一件一件的做。如果有一个任务消耗的时间太长,后面的任务就不会执行,浏览器会呈现卡死的状态,这样的用户体验就会非常差。

对于优化这种问题,有三个方向:

  • 让每个任务都迅速完成
  • 一个任务短时间无法完成就中断,执行另一个任务,不阻塞交互,让用户觉得够快
  • 非要多线程

Vue选择的是第一种,有兴趣的同学可以看一下9102年Vue Conf 尤雨溪的演讲;而React选择了第二种方式,最后一种也有人尝试,但要保证状态和视图的一致性相当麻烦。

接下来让我们看一下React使用第二种方案优化之后的成果:

优化之前:

优化之后:

在React没有使用第二种优化方案的年代,react的生命周期中,有下面这几个阶段:

  • componentWillMount
  • componentWillReceiveProps
  • shouldComponentUpdate
  • componentWillUpdate

这几个阶段涉及到组件状态的更新,虚拟dom树的对比找出变动的节点,更新真实dom元素并渲染。因此上面这几个生命周期所涉及的流程统称为:调和阶段——Reconcilation

在 Reconcilation 期间,React 会霸占着浏览器资源,一则会导致用户触发的事件得不到响应, 二则会导致掉帧,用户可以感知到这些卡顿。

这里我们细说下React后来是怎样优化的:

为了给用户制造一种应用很快的’假象’,我们不能长时间执行一个任务,比如说览器的渲染、布局、绘制、资源加载(例如HTML解析)、事件响应、脚本执行,我们需要通过某些策略合理执行每个任务,从而提高浏览器的用户响应速率, 同时兼顾执行效率。

那我们说的策略是什么呢?

React通过Fiber架构,让自己的Reconcilation过程变成可被中断。 ‘适时’地让出任务执行权,除了可以让浏览器及时地响应用户的交互,还有其他作用:

  • 与其一次性操作大量 DOM 节点相比, 分批延时对DOM进行操作,可以得到更好的用户体验。
  • 给浏览器一点喘息的机会,他会对代码进行编译优化(JIT)及进行热代码优化,或者对reflow进行修正。

什么时候中断好

浏览器的每一帧

页面是一帧一帧绘制出来的,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿。

1s 60 帧,所以每一帧分到的时间是 1000/60 ≈ 16 ms。所以我们书写代码时力求不让一帧的工作量超过 16ms。

由上图得知,一帧当中大致有如下工作:

  • 处理用户的交互
  • JS 解析执行
  • 帧开始。窗口尺寸变更,页面滚去等的处理
  • 调用rAF(requestAnimationFrame)
  • 布局
  • 绘制

如果这六个步骤中,任意一个步骤所占用的时间过长,总时间超过 16ms 了之后,用户也许就能看到卡顿。

而在上一小节提到的Reconcilation(协调)阶段花的时间过长,也就是 js 执行的时间过长,那么就有可能在用户有交互的时候,本来应该是渲染下一帧了,但是在当前一帧里还在执行 JS,就导致用户交互不能马上得到反馈,从而产生卡顿感。

保证每一帧的时间

通过requestIdleCallback实现时间分片

当关注用户体验,不希望因为一些不重要的任务导致用户感觉到卡顿的话,可以使用requestIdleCallback,因为它是在浏览器两个执行帧之间的空闲期调用的回调函数(对于不支持这个API 的浏览器,React会自己实现一个)。

我们把两个执行帧之间的空闲期叫做时间片(下文会用到)

requestIdleCallback用法示例:

1
2
3
4
5
6
7
8
9
10
11
const myNonEssentialWork = deadline => {
// deadline.timeRemaining()可以获取到当前帧剩余时间
while (deadline.timeRemaining() > 0 && tasks.length > 0) {
doWorkIfNeeded();
}
if (tasks.length > 0){
requestIdleCallback(myNonEssentialWork);
}
}

window.requestIdleCallback(myNonEssentialWork);

但是在浏览器繁忙的时候,可能不会有盈余时间,这时候requestIdleCallback回调可能就不会被执行。 为了避免这种情况,可以通过requestIdleCallback的第二个参数指定一个超时时间。

timeout: 如果指定了timeout并具有一个正值,并且尚未通过超时毫秒数调用回调,那么回调会在下一次空闲时期被强制执行,尽管这样很可能会对性能造成负面影响。

1
2
3
4
5
6
7
8
9
10
11
const myNonEssentialWork = deadline => {
// 当回调函数是由于超时才得以执行的话,deadline.didTimeout为true
while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && tasks.length > 0) {
doWorkIfNeeded();
}
if (tasks.length > 0){
requestIdleCallback(myNonEssentialWork);
}
}

requestIdleCallback(myNonEssentialWork, { timeout: 2000 });

如何实现中断

调和阶段与提交阶段

新的React版本(v16)将渲染分为两个阶段:协调阶段和提交阶段

协调阶段: 可以认为是 Diff 阶段, 这个阶段可以被中断, 这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等, 这些变更React称之为’副作用(Effect)’ 。以下生命周期钩子会在协调阶段被调用:

  • constructor
  • componentWillMount 废弃
  • componentWillReceiveProps 废弃
  • static getDerivedStateFromProps
  • shouldComponentUpdate
  • componentWillUpdate 废弃
  • render

提交阶段: 将上一个阶段计算出来的需要处理的副作用(Effects)一次性执行了。这个阶段必须同步执行,不能被打断. 这些生命周期钩子在提交阶段被执行:

  • getSnapshotBeforeUpdate() 严格来说,这个是在进入 commit 阶段前调用
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

也就是说,在协调阶段如果时间片用完,React就会选择让出控制权让浏览器渲染下一帧。因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。

需要注意的是:因为协调阶段可能被中断、恢复,甚至重做,React协调阶段的生命周期钩子可能会被调用多次, 例如componentWillMount可能会被调用两次。

因此建议 协调阶段的生命周期钩子不要包含副作用. 索性 React 就废弃了这部分可能包含副作用的生命周期方法,例如componentWillMount、componentWillUpdate。React(v17)后我们就不能再用它们了, 所以现有的应用应该尽快迁移.

为什么提交阶段必须同步执行,不能中断?

因为我们要正确地处理各种副作用,包括DOM变更、还有你在componentDidMount中发起的异步请求、useEffect 中定义的副作用… 因为有副作用,所以必须保证按照次序只调用一次,况且会有用户可以察觉到的变更, 不容差池。

React Fiber的结构

React是如何让执行的任务能够随意中断、恢复的呢?

React目前的做法是使用链表, 每个VirtualDOM节点内部现在使用Fiber表示, 它的结构大概如下:

1
2
3
4
5
6
7
8
export type Fiber = {
// Fiber 类型信息
type: any,
// ...// ⚛️ 链表结构// 指向父节点,或者render该节点的组件return: Fiber | null,// 指向第一个子节点
child: Fiber | null,
// 指向下一个兄弟节点
sibling: Fiber | null,
}

那么对于下面这样的根组件:

1
2
3
4
5
6
7
8
function App() {
return (
<div class = "app">
<h1>hello</h1>
<p>world</p>
</div>
)
}

就会生成这样的Fiber结构:

一份更完整的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
interface Fiber {
/**
* ⚛️ 节点的类型信息
**/
// 标记 Fiber 类型, 例如函数组件、类组件、宿主组件
tag: WorkTag,
// 节点元素类型, 是具体的类组件、函数组件、宿主组件(字符串)
type: any,

/**
* ⚛️ 结构信息
*/
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
// 子节点的唯一键, 即我们渲染列表传入的key属性
key: null | string,

/**
* ⚛️ 节点的状态
*/
// 节点实例(状态):
// 对于宿主组件,这里保存宿主组件的实例, 例如DOM节点。
// 对于类组件来说,这里保存类组件的实例
// 对于函数组件说,这里为空,因为函数组件没有实例
stateNode: any,
// 新的、待处理的props
pendingProps: any,
// 上一次渲染的props
memoizedProps: any,
// The props used to create the output.
// 上一次渲染的组件状态
memoizedState: any,

/**
* ⚛️ 副作用
*/// 当前节点的副作用类型,例如节点更新、删除、移动
effectTag: SideEffectTag,
// 和节点关系一样,React 同样使用链表来将所有有副作用的Fiber连接起来
nextEffect: Fiber | null,

/**
* ⚛️ 替身
* 指向旧树中的节点
*/
alternate: Fiber | null,
}

Fiber 包含的属性可以划分为 5 个部分:

  • 结构信息 - 这个上文我们已经见过了,Fiber 使用链表的形式来表示节点在树中的定位
  • 节点类型信息 - 这个也容易理解,tag表示节点的分类、type 保存具体的类型值,如div、MyComp
  • 节点的状态 - 节点的组件实例、props、state等,它们将影响组件的输出
  • 副作用 - 这个也是新东西. 在 Reconciliation 过程中发现的’副作用’(变更需求)就保存在节点的effectTag 中(想象为打上一个标记). 那么怎么将本次渲染的所有节点副作用都收集起来呢? 这里也使用了链表结构,在遍历过程中React会将所有有‘副作用’的节点都通过nextEffect连接起来
  • 替身 - React 在 Reconciliation 过程中会构建一颗新的树(WIP树),可以认为是一颗表示当前工作进度的树。还有一颗表示已渲染界面的旧树,React就是一边和旧树比对,一边构建WIP树的。 alternate 指向旧树的同等节点。
  • 时间片轮转调度与组件调和

    时间片轮转调度执行

    在浏览器空闲时执行,每次执行完一个’执行单元Fiber’, React 就会检查现在还剩多少时间,如果没有时间就停止执行,让浏览器去执行下一帧的渲染。

假设用户调用setState更新组件, 这个待更新的任务会先放入队列中, 然后通过requestIdleCallback判断是否执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
updateQueue.push(updateTask);
requestIdleCallback(performWork, {timeout});

// performWork 会拿到一个Deadline,表示剩余时间
const performWork = deadline => {
// 循环取出updateQueue中的任务
while (updateQueue.length > 0 && deadline.timeRemaining() > ENOUGH_TIME) {
workLoop(deadline);
}

// 如果在本次执行中,未能将所有任务执行完毕,那就再请求浏览器调度
if (updateQueue.length > 0) {
requestIdleCallback(performWork);
}
}

任务的中断与现场保留

代码中workLoop函数会从更新队列(updateQueue)中弹出更新任务来执行,每执行完一个‘执行单元‘,就检查一下剩余时间是否充足,如果充足就进行执行下一个执行单元,反之则停止执行,保存现场,等下一次有执行权时恢复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 保存当前的处理现场
let nextUnitOfWork: Fiber | undefined // 保存下一个需要处理的工作单元
let topWork: Fiber | undefined // 保存第一个工作单元

const workLoop = (deadline: IdleDeadline) => {
// updateQueue中获取下一个或者恢复上一次中断的执行单元
if (nextUnitOfWork == null) {
nextUnitOfWork = topWork = getNextUnitOfWork();
}

// 每执行完一个执行单元,检查一次剩余时间
// 如果被中断,下一次执行还是从 nextUnitOfWork 开始处理
while (nextUnitOfWork && deadline.timeRemaining() > ENOUGH_TIME) {// 下文我们再看performUnitOfWork
// 获取下一个需要处理的任务单元
nextUnitOfWork = performUnitOfWork(nextUnitOfWork, topWork);
}

// 提交工作,下文会介绍
if (pendingCommit) {
commitAllWork(pendingCommit);
}
}

上面的过程可以用下图加以描述:

接下来来看看performUnitOfWork的实现, 它其实就是一个深度优先的遍历:

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
/**
* @params fiber 当前需要处理的节点
* @params topWork 本次更新的根节点
*/
function performUnitOfWork(fiber: Fiber, topWork: Fiber) {
// 对该节点进行处理
beginWork(fiber);

// 如果存在子节点,那么下一个待处理的就是子节点
if (fiber.child) {
return fiber.child;
}

// 没有子节点了,上溯查找兄弟节点
let temp = fiber;
while (temp) {
completeWork(temp);

// 到顶层节点了, 退出
if (temp === topWork) {
break
}

// 找到,下一个要处理的就是兄弟节点
if (temp.sibling) {
return temp.sibling;
}

// 没有, 继续上溯
temp = temp.return;
}
}

因为使用了链表结构,即使处理流程被中断了,我们随时可以从上次未处理完的Fiber继续遍历下去。

下图假设在div.app进行了更新:

举个例子:比如你在text(hello)中断了,那么下一次就会在回溯过程中从p节点开始处理

这个数据结构调整还有一个好处,就是某些节点异常时,我们可以打印出完整的’节点栈‘,只需要沿着节点的return回溯即可。

Reconcilation

接下来就是就是我们熟知的Reconcilation(为了方便理解,本文不区分Diff和Reconcilation, 两者是同一个东西)阶段了

现在可以放大看看beginWork 是如何对 Fiber 进行比对的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function beginWork(fiber: Fiber): Fiber | undefined {
if (fiber.tag === WorkTag.HostComponent) {
// 宿主节点diff
diffHostComponent(fiber)
} else if (fiber.tag === WorkTag.ClassComponent) {
// 类组件节点diff
diffClassComponent(fiber)
} else if (fiber.tag === WorkTag.FunctionComponent) {
// 函数组件节点diff
diffFunctionalComponent(fiber)
} else {
// ... 其他类型节点,省略
}
}

宿主节点的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
function diffHostComponent(fiber: Fiber) {
// 新增节点
if (fiber.stateNode == null) {
fiber.stateNode = createHostComponent(fiber)
} else {
updateHostComponent(fiber)
}

const newChildren = fiber.pendingProps.children;

// 比对子节点
diffChildren(fiber, newChildren);
}

类组件对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function diffClassComponent(fiber: Fiber) {
// 创建组件实例
if (fiber.stateNode == null) {
fiber.stateNode = createInstance(fiber);
}

if (fiber.hasMounted) {
// 调用更新前生命周期钩子
applybeforeUpdateHooks(fiber)
} else {
// 调用挂载前生命周期钩子
applybeforeMountHooks(fiber)
}

// 渲染新节点const newChildren = fiber.stateNode.render();// 比对子节点
diffChildren(fiber, newChildren);

fiber.memoizedState = fiber.stateNode.state
}

子节点比对:

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
function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
let oldFiber = fiber.alternate ? fiber.alternate.child : null;
// 全新节点,直接挂载
if (oldFiber == null) {
mountChildFibers(fiber, newChildren)
return
}

let index = 0;
let newFiber = null;
// 新子节点
const elements = extraElements(newChildren)

// 比对子元素
while (index < elements.length || oldFiber != null) {
const prevFiber = newFiber;
const element = elements[index]
const sameType = isSameType(element, oldFiber)
if (sameType) {
newFiber = cloneFiber(oldFiber, element)
// 更新关系
newFiber.alternate = oldFiber
// 打上Tag
newFiber.effectTag = UPDATE
newFiber.return = fiber
}

// 新节点
if (element && !sameType) {
newFiber = createFiber(element)
newFiber.effectTag = PLACEMENT
newFiber.return = fiber
}

// 删除旧节点
if (oldFiber && !sameType) {
oldFiber.effectTag = DELETION;
oldFiber.nextEffect = fiber.nextEffect
fiber.nextEffect = oldFiber
}

if (oldFiber) {
oldFiber = oldFiber.sibling;
}

if (index == 0) {
fiber.child = newFiber;
} else if (prevFiber && element) {
prevFiber.sibling = newFiber;
}

index++
}
}

双缓存渲染

React在渲染过程中有两棵渲染树,一棵新树一棵旧的,WIP树的节点不完全是新的,某颗子树不需要变动,React会克隆复用旧树中的子树。

而且当一个节点抛出异常,仍然可以继续沿用旧树的节点,避免整棵树挂掉。

它就像git分支一样,你可以将 WIP 树想象成从旧树中 Fork 出来的功能分支,你在这新分支中添加或移除特性,即使是操作失误也不会影响旧的分支。当你这个分支经过了测试和完善,就可以合并到旧分支,将其替换掉。

提交渲染

最后就是将所有打了Effect标记的节点串联起来进行渲染了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function completeWork(fiber) {
const parent = fiber.return

// 到达顶端if (parent == null || fiber === topWork) {
pendingCommit = fiber
return
}

if (fiber.effectTag != null) {
if (parent.nextEffect) {
parent.nextEffect.nextEffect = fiber
} else {
parent.nextEffect = fiber
}
} else if (fiber.nextEffect) {
parent.nextEffect = fiber.nextEffect
}
}

最后了,将所有副作用提交了:

1
2
3
4
5
6
7
8
9
10
11
12
13
function commitAllWork(fiber) {
let next = fiber
while(next) {
if (fiber.effectTag) {
// 提交,这里就不展开了
commitWork(fiber)
}
next = fiber.nextEffect
}

// 清理现场
pendingCommit = nextUnitOfWork = topWork = null
}
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2015-2021 AURORA_ZXH
  • Visitors: | Views:

请我喝杯咖啡吧~

支付宝
微信