React Fiber 架构浅析

971 阅读10分钟

1. 浏览器渲染基础

1.1 渲染帧

帧(frame):动画过程中,每一幅静止的画面叫做帧

帧率(frame per second):每秒连续播放的静止画面的数量

帧时长(frame running time):每一幅静止的画面的停留时间

丢帧(dropped frame):当某一帧时长高于平均帧时长

  • 一般来说浏览器刷新率在60Hz,渲染一帧的时间必须控制在16.67ms(1s/60=16.67ms)
  • 如果渲染超过该时间,对用户视觉上来说,就会出现卡顿现象,即丢帧(dropped frame)

1.2 帧生命周期

帧生命周期

简单描述帧生命周期

  1. 首先处理输入事件,能让用户得到最快的反馈

  2. 接下来是处理定时器,需要检查定时器是否到时间,并执行对应的回调函数

  3. 接下来处理Begin Frame(开始帧),即每一帧的事件,包括window.resizescroll

  4. 接下来执行请求动画帧requestAnimationFrame(rAF),每次在绘制之前,会执行rAF回调

  5. 接着进行layout操作,计算布局和更新布局,即元素的样式是怎样的,应该在页面上如何展示

  6. 进行paint操作,得到树中每个节点的尺寸与位置,浏览器对每个元素进行内容填充

此时上面步骤完成后,如果还有空闲时间(Idle Period),执行RequestIdleCallback函数里注册的任务(它就是React Fiber任务调度实现的基础)

1.3 丢帧实验

为什么会丢帧?

对于流畅的动画,如果对一帧画面的处理时间超过16.67ms,就能感到卡顿,下面链接是模拟丢帧实验

Demo: linjiayu6.github.io/FE-RequestI…

dropped frame.gif

当用户点击任一按键A,B,C时,因主线程执行click event任务,动画因浏览器不能及时处理下一帧,导致出现卡顿现象 主要逻辑代码如下

// 处理同步任务,并占用主线程

const bindClick = id =>

element(id).addEventListener('click'Work.onSyncUnit)

// 绑定click事件

bindClick('btnA')

bindClick('btnB')

bindClick('btnC')

var Work = {

// 有1万个任务

unit10000,

// 处理每个任务

onOneUnitfunction () { for (var i = 0; i <= 500000; i++) {} },

// 同步处理所有的任务

onSyncUnitfunction () {

let _u = 0

while (_u < Work.unit) {

Work.onOneUnit()

_u ++

}

}

}

1.4 解决掉帧问题

前面提到当正常帧任务完成时间在16ms内,会有空闲的时间,就会执行requestIdleCallback函数里注册的任务,这个就是React Fiber实现的基础api。先来看看requestIdleCallback在每帧的调用

image.png

  1. 低优先级任务由requestIdleCallback处理

  2. 高优先级任务如与动画相关的由requestAnimationFrame处理

  3. requestIdleCallback可以在多个空闲期间调用,执行任务

  4. window.requestIdleCallback(callback)callback中会接受到默认参数deadline,其中包含了以下来两个属性:

    • timeRamining返回当前帧还剩多少时间可用
    • didTimeout返回callback任务是否超时

下面我们就对前面的实验进行改造:

  1. 利用RequestIdleCallback处理任务

  2. 将高耗时的任务拆解,分步在idle period里面执行

逻辑如下:

const bindClick = id =>

element(id).addEventListener('click'Work.onAsyncUnit)

// 绑定定click事件

bindClick('btnA')

bindClick('btnB')

bindClick('btnC')

var Work = {

// 有1万个任务

unit10000,

// 处理每个任务

onOneUnitfunction () { for (var i = 0; i <= 500000; i++) {} },

// 异步处理

onAsyncUnitfunction () {

// 空闲时间 1ms

const FREE_TIME = 1

let _u = 0

function cb(deadline) {

// 当任务还没有处理完 & 一帧还有的空闲时间 > 1ms

while (_u < Work.unit && deadline.timeRemaining() > FREE_TIME) {

Work.onOneUnit()

//计算执行的次数,到10000就跳出循环,如果未到10000以便下面判断,继续放入到空闲执行

_u ++

}

// 任务完成, 执行回调

if (_u >= Work.unit) {

// 执行回调

return

}

// 任务没完成, 继续等待空闲执行

window.requestIdleCallback(cb)

}

//刚开始点击时,就将耗时任务放进空闲时间中处理

window.requestIdleCallback(cb)

}

}

效果如下:

normal frame.gif

可以看到帧率都是在60fps左右。

image.png

值得注意的是,requestIdleCallback里应该避免执行长时间的任务,否则可能会阻塞页面渲染

image.png

如果在requestIdleCallback里执行一个长时间的任务,在第一帧时间不够执行后,它会抢夺第二帧的一些时间,从而造成卡顿。

2. React Fiber架构

image.png

image.png

React15架构的缺点:

  1. 使用递归遍历更新树,不能中断

  2. 有用户高优先级操作的比如点击事件 动画等,必须等待主线程释放才能响应,会造成丢帧

为了解决上述的问题,React团队对核心算法进行重构,重构的产物就是Fiber reconciler,过程如下:

image.png

注意:schedulerFiber Reconciler这个时期是可中断的, commit时期是不可中断的

React的fiber架构更改:

  1. 将树的结构重构为多项链表结构,递归算法重构为深度优先遍历算法

  2. 将无法中断的更新重构为异步的可中断的更新

  3. 将更新碎片化,每执行完一部分,查看是否有高优先级任务,如果有,记录状态,先去执行高优先级任

务,在下一个空闲时间再执行剩下部分

2.1 Scheduler(调度阶段)

React Fiber的构建不是一蹴而就,它是每个fiber作为一个工作单元(fiber tree上的一个节点)进行工作循环,shouldYieldToRenderer看看分配给任务的时间用完了没,没用完就处理下一个任务,否则释放主线程,等待等下一次requestIdleCallback回调接着做。

// Flush asynchronous work until there's 在render进行渲染a higher priority event
while (nextUnitOfWork !== null && !shouldYieldToRenderer()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

原理图如下:

image.png

每个工作单元运行时有6种优先级:

  • synchronous同步执行
  • task 在next tick之前执行
  • animation 下一帧之前执行
  • high 在不久的将来立即执行
  • low 稍微延迟(100-200ms)执行也没关系
  • offscreen 下一次render时或scroll时才执行

synchronous首屏(首次渲染)用,要求尽量快,不管会不会阻塞UI线程。

animation通过requestAnimationFrame来调度,这样在下一帧就能立即开始动画过程;

后3个都是由requestIdleCallback回调执行的;

offscreen指的是当前隐藏的、屏幕外的(看不见的)元素

高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等.

每一种优先级都会分配一定的expirationTime,时间越短,优先级越高。

React16 的 expirationTimes 模型只能区分是否>=expirationTimes決定节点是否更新。

这样的优先级机制存在2个问题

  • 生命周期函数怎么执行(可能被频频中断):触发顺序、次数没有保证了

  • starvation(低优先级饿死):如果高优先级任务很多,那么低优先级任务根本没机会执行(就饿死了)

React17 的 lanes 模型 可以选定一个更新区间,并且动态的向区间中增减优先级,可以处理更细粒度的更新。

2.2 Fiber Reconciler(协调阶段)

这个过程是diff的过程,也是effect收集的过程,找出所有节点的变更,比如节点新增,删除,属性变更等,这些变更统称为副作用(effect),最后的结果是生成一个 effect list,在后面render里,就根据 effect list在commit阶段进行渲染

2.2.1 fiber节点属性

由于基于时间分片的更新,就要更多的上下文信息,当切换高优先级任务时,记住当前的节点信息,以便下次空闲时间可以继续执行任务

fiber节点的属性有很多,大家需要关注的有这几个 return、child、sibling(这几个主要负责 fiber链表的连接);stateNode;effectTag;expirationTime;alternate;nextEffect

class FiberNode {
constructor(tag, pendingProps, key, mode) {
  // 实例属性
  this.tag = tag; // 标记不同组件类型,如函数组件、类组件、文本、原生组件...
  this.key = key; // react 元素上的 key 就是 jsx 上写的那个 key ,也就是最终 ReactElement 上的
  this.elementType = null; // createElement的第一个参数,ReactElement 上的 type
  this.type = null; // 表示fiber的真实类型 ,elementType 基本一样,在使用了懒加载之类的功能时可能会不一样
  this.stateNode = null; // 实例对象,比如 class 组件 new 完后就挂载在这个属性上面,如果是RootFiber,那么它上面挂的是 FiberRoot,如果是原生节点就是 dom 对象
  // fiber
  this.return = null; // 父节点,指向上一个 fiber
  this.child = null; // 子节点,指向自身下面的第一个 fiber
  this.sibling = null; // 兄弟组件, 指向一个兄弟节点
  this.index = 0; //  一般如果没有兄弟节点的话是0 当某个父节点下的子节点是数组类型的时候会给每个子节点一个 index,index 和 key 要一起做 diff
  this.ref = null; // reactElement 上的 ref 属性
  this.pendingProps = pendingProps; // 新的 props
  this.memoizedProps = null; // 旧的 props
  this.updateQueue = null; // fiber 上的更新队列执行一次 setState 就会往这个属性上挂一个新的更新, 每条更新最终会形成一个链表结构,最后做批量更新
  this.memoizedState = null; // 对应  memoizedProps,上次渲染的 state,相当于当前的 state,理解成 prev 和 next 的关系
  this.mode = mode; // 表示当前组件下的子组件的渲染方式
  // effects
  this.effectTag = NoEffect; // 表示当前 fiber 要进行何种更新(更新、删除等)
  this.nextEffect = null; // 指向下个需要更新的fiber
  this.firstEffect = null; // 指向所有子节点里,需要更新的 fiber 里的第一个
  this.lastEffect = null; // 指向所有子节点中需要更新的 fiber 的最后一个
  this.expirationTime = NoWork; // 过期时间,代表任务在未来的哪个时间点应该被完成
  this.childExpirationTime = NoWork; // child 过期时间
  this.alternate = null; // current 树和 workInprogress 树之间的相互引用
}
}

2.2.2 遍历流程

React 16中大量地使用了链表这样的数据结构。使用多向链表替换了之前的树结构

image.png

过程如下:

  1. 从顶点开始遍历

  2. 有子节点,先遍历子节点

  3. 如果没有子节点,看是否有兄弟节点,有则遍历兄弟节点,并把effect向上归并

  4. 如果没有兄弟节点,那看父节点有没有兄弟节点,有的话遍历父节点的兄弟节点

  5. 如果都没有则遍历结束

2.2.2 副作用链

进行更新workInProgress(简称WIP) tree的时候,这棵树在构建每个fiber节点时,会收集这个节点的副作用信息, 当WIP tree构建完成后,將有副作用的节点通过 firstEffect、lastEffect、nextEffect 形成一条副作用单链表,最后在渲染阶段,通过副作用链完成更新


<div id="A1">
  A1
  <div id="B1">
    B1
    <div id="C1">C1</div>
    <div id="C2">C2</div>
  </div>
  <div id="B2">
    B2
  </div>
</div>

上面的代码会形成如下的副作用链

image.png

2.2.2 Reconciliation过程

image.png 上图就是完成Reconciliation后的状态,左边是旧树,右边是WIP树,对于需要变更的节点,标记effectTag信息,在最后的render阶段,根据effectTag进行应用变更。 具体的流程如下:

  1. 如果当前节点不需要更新,直接把子节点clone过来,跳到5;要更新的话打个tag

  2. 更新当前节点状态(props, state, context等)

  3. 调用shouldComponentUpdate()false的话,跳到5

  4. 调用render()获得新的子节点,并为子节点创建fiber(创建过程会尽量复用现有fiber,子节点增删也发生在这里)

  5. 如果没有产生child fiber,该工作单元结束,把effect list归并到return,并把当前节点的sibling作为下一个工作单元;否则把child作为下一个工作单元

  6. 如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元;否则,立即开始做

  7. 如果没有下一个工作单元了(回到了workInProgress tree的根节点),第1阶段结束,进入pendingCommit状态

实际上1-6是任务循环,7为最后的结果。任务循环结束后,WIP tree的根节点身上的effect list就是收集到的所有副作用(side effect)(因为每做完一个都向上归并)

所以,构建workInProgress tree的过程就是diff的过程,通过requestIdleCallback来调度执行一组任务,每完成一个任务后回来看看有没有插队的(高优先级任务),每完成一组任务,把时间控制权交还给主线程,直到下一次requestIdleCallback回调再继续构建workInProgress tree

2.2.2 双缓存技术

image.png

构建完成后就存在两棵树:分別叫做current fiber treeworkInProgress fiber tree,可以看到,构建时,WIP tree是以current fiber tree为模板进行变更,得到的就是新的fiber tree,接着current指针指向WIP tree

再把旧的fiber tree放一边,它们的fiber节点是通过alternate属性相互建立连接,旧fiber就作为新fiber更新的预留空间,达到复用fiber实例的目的。 好处是:

  • 能够复用内部对象(fiber)
  • 节省内存分配、GC的时间开销

2.3 Commit(渲染阶段)

这个阶段是不中断的执行:

  1. 处理effect list,根据变更信息进行操作(包括3种处理:更新DOM树、调用组件生命周期函数以及更新ref等内部状态)

  2. 处理结束后,把变更都commit到DOM树上

这个过程是同步过程,在这个过程的生命周期里不要干重活

从构建过程生命周期函数可以分为2类

// 第1阶段 render/reconciliation

componentWillMount

componentWillReceiveProps

shouldComponentUpdate

componentWillUpdate

// 第2阶段 commit

componentDidMount

componentDidUpdate

componentWillUnmount

参考资料:

  1. # Lin Clark - A Cartoon Intro to Fiber - React Conf 2017

  2. github.com/acdlite/rea…

  3. www.readfog.com/a/164644909…

  4. www.readfog.com/a/163598702…

  5. mp.weixin.qq.com/s/gz7_StDD1…

  6. segmentfault.com/a/119000003…

  7. juejin.cn/post/684490…