聊聊 React Fiber

1,559 阅读13分钟

Fiber背景

同步更新过程的局限

在现有React中,更新过程是同步的,这可能会导致性能问题。

当React决定要加载或者更新组件树时,会做很多事,比如调用各个组件的生命周期函数,计算和比对Virtual DOM,最后更新DOM树,这整个过程是同步进行的,也就是说只要一个加载或者更新过程开始,那React就以不破楼兰终不还的气概,一鼓作气运行到底,中途绝不停歇。

表面上看,这样的设计也是挺合理的,因为更新过程不会有任何I/O操作嘛,完全是CPU计算,所以无需异步操作,的确只要一路狂奔就行了,但是,当组件树比较庞大的时候,问题就来了。 假如更新一个组件需要1毫秒,如果有200个组件要更新,那就需要200毫秒,在这200毫秒的更新过程中,浏览器那个唯一的主线程都在专心运行更新操作,无暇去做任何其他的事情。想象一下,在这200毫秒内,用户往一个input元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被React占着呢,抽不出空,最后的结果就是用户敲了按键看不到反应,等React更新过程结束之后,咔咔咔那些按键一下子出现在input元素里了。 这就是所谓的界面卡顿,很不好的用户体验。

简单一点来说就是,react更新过程,如果更新几百个组件,这种遍历是递归调用,执行栈会越来越深。而且 不能中断,因为中断后再想恢复 就非常难了,遍历很深的话,因为JavaScript单线程的特点,每个同步任务不能耗时太长,不然就会让程序不会对其他输入作出相应,React的更新过程就是犯了这个禁忌,而React Fiber就是要改变现状。

而fiber实现原理是:把整个任务分解成很多小任务,每次执行一个任务后看一下有没有剩余时间,如果有,继续下一个任务,如果没有时间,则放弃执行,交给浏览器进行调度

为什么需要Fiber

因此,为了解决以上的痛点问题,React希望能够彻底解决主线程长时间占用问题,于是引入了 Fiber 来改变这种不可控的现状,把渲染/更新过程拆分为一个个小块的任务,通过合理的调度机制来调控时间,指定任务执行的时机,从而降低页面卡顿的概率,提升页面交互体验。通过Fiber架构,让reconcilation过程变得可被中断。适时地让出CPU执行权,可以让浏览器及时地响应用户的交互。

概念

Fiber 可以理解为是一种数据结构,也可以理解为是一个执行单元。

一种数据结构

Fiber 可以理解为是一种数据结构,节点数据结构如下:

Fiber = {
    // 标识 fiber 类型的标签,详情参看下述 WorkTag
    tag: WorkTag,

    // 指向父节点
    return: Fiber | null,

    // 指向子节点
    child: Fiber | null,

    // 指向兄弟节点
    sibling: Fiber | null,

    // 在开始执行时设置 props 值
    pendingProps: any,

    // 在结束时设置的 props 值
    memoizedProps: any,

    // 当前 state
    memoizedState: any,

    // Effect 类型,详情查看以下 effectTag
    effectTag: SideEffectTag,

    // effect 节点指针,指向下一个 effect
    nextEffect: Fiber | null,

    // effect list 是单向链表,第一个 effect
    firstEffect: Fiber | null,

    // effect list 是单向链表,最后一个 effect
    lastEffect: Fiber | null,

    // work 的过期时间,可用于标识一个 work 优先级顺序
    expirationTime: ExpirationTime,
};

通俗易懂的说,所有的element都是一个独立的fiber,element的同级元素用sibling链接,子元素用child链接,这样就由上至下形成了一个fiber tree。

React Fiber 就是采用链表实现的,每个Fiber保存了节点处理的上下文信息,因为是手动实现的,所以更为可控,我们可以保存在内存中,随时中断和恢复。每个 Virtual DOM 都可以表示为一个 fiber,如下图所示,每个节点都是一个 fiber。一个 fiber包括了 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,React Fiber 机制的实现,就是依赖于以下的数据结构。

Fiber链表结构设计

Fiber结构是使用链表实现的,Fiber tree实际上是个单链表树结构,详见ReactFiber.js源码,在这里我们看看Fiber的链表结构是怎样的,了解了这个链表结构后,能更快地理解后续 Fiber 的遍历过程。

undefined

以上每一个单元包含了payload(数据)和nextUpdate(指向下一个单元的指针),定义结构如下:

class Update {
  constructor(payload, nextUpdate) {
    this.payload = payload // payload 数据
    this.nextUpdate = nextUpdate // 指向下一个节点的指针
  }
}
复制代码

接下来定义一个队列,把每个单元串联起来,其中定义了两个指针:头指针firstUpdate和尾指针lastUpdate,作用是指向第一个单元和最后一个单元,并加入了baseState属性存储React中的state状态。如下所示:

class UpdateQueue {
  constructor() {
    this.baseState = null // state
    this.firstUpdate = null // 第一个更新
    this.lastUpdate = null // 最后一个更新
  }
}
复制代码

接下来定义两个方法:插入节点单元(enqueueUpdate)、更新队列(forceUpdate)。插入节点单元时需要考虑是否已经存在节点,如果不存在直接将firstUpdatelastUpdate指向此节点即可。更新队列是遍历这个链表,根据payload中的内容去更新state的值。

class UpdateQueue {
  //.....
  
  enqueueUpdate(update) {
    // 当前链表是空链表
    if (!this.firstUpdate) {
      this.firstUpdate = this.lastUpdate = update
    } else {
      // 当前链表不为空
      this.lastUpdate.nextUpdate = update
      this.lastUpdate = update
    }
  }
  
  // 获取state,然后遍历这个链表,进行更新
  forceUpdate() {
    let currentState = this.baseState || {}
    let currentUpdate = this.firstUpdate
    while (currentUpdate) {
      // 判断是函数还是对象,是函数则需要执行,是对象则直接返回
      let nextState = typeof currentUpdate.payload === 'function' ? currentUpdate.payload(currentState) : currentUpdate.payload
      currentState = { ...currentState, ...nextState }
      currentUpdate = currentUpdate.nextUpdate
    }
    // 更新完成后清空链表
    this.firstUpdate = this.lastUpdate = null
    this.baseState = currentState
    return currentState
  }
}
复制代码

最后写一个demo,实例化一个队列,向其中加入很多节点,再更新这个队列:

let queue = new UpdateQueue()
queue.enqueueUpdate(new Update({ name: 'www' }))
queue.enqueueUpdate(new Update({ age: 10 }))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.enqueueUpdate(new Update(state => ({ age: state.age + 1 })))
queue.forceUpdate()
console.log(queue.baseState);
复制代码

打印结果如下:

{ name:'www',age:12 }

一个执行单元

Fiber 可以理解为一个执行单元,每次执行完一个执行单元,react 就会检查现在还剩多少时间,如果没有时间则将控制权让出去。Fiber 可以被理解为划分一个个更小的执行单元,它是把一个大任务拆分为了很多个小块任务,一个小块任务的执行必须是一次完成的,不能出现暂停,但是一个小块任务执行完后可以移交控制权给浏览器去响应用户,从而不用像之前一样要等那个大任务一直执行完成再去响应用户。

执行原理

任务调度与任务优先级

任务的拆分、执行、挂起、恢复以及高优先级任务插队是 react 更新任务的核心。

拆分

每一个dom元素就是一个Fiber,而一个 Fiber 可以理解为一个执行单元,所以一次更新任务被拆分成了以Fiber为单位的小任务。

执行、挂起、恢复 假设用户调用 setState 更新组件, 这个待更新的任务会先放入队列中, 然后通过 requestIdleCallback 请求浏览器调度:浏览器有空闲时就会来执行任务,每执行完一个执行单元,就检查一下剩余时间是否充足以及是否有剩余的执行单元,如果没有了任务则退出,如果时间充足且有剩余的任务就执行下一个执行单元,反之则停止执行,记录下一次要执行的执行单元,等下一次有执行权时恢复执行。使用方法如下:window.requestIdleCallback(callback)。callback就是更新函数,会接收到默认参数 deadline ,其中包含了以下两个属性:

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

requestIdleCallback调度fiber更新任务的伪代码如下:

let firstFiber
let nextFiber = firstFiber
let shouldYield = false

//firstFiber->firstChild->sibling
function performUnitOfWork(nextFiber){
  //...
  return nextFiber.next
}

function workLoop(deadline){
  while(nextFiber && deadline.timeReaming > 1){
          nextFiber = performUnitOfWork(nextFiber)
        }
  requestIdleCallback(workLoop)
}

requestIdleCallback(workLoop)

任务优先级

React 16 利用 expirationTimes模型 来实现任务优先级,每一种不同的任务会分配一个过期时间,过期时间 = 每种任务计算出一个常量 + 任务的触发时间,任务的具体优先级计算公式为 优先级 = 一个很大的常量 - 过期时间,得到的值越大,优先级越高。

React 17 改变了优先级模型为 Lane 模型

优先级批次

除此之外,还有个问题需要解决:如何表示批次?

批次是什么?考虑如下例子:

// 定义状态num
const [num, updateNum] = useState(0);

// ...某些修改num的地方
// 修改的方式1
updateNum(3);
// 修改的方式2
updateNum(num => num + 1);

两种 修改状态的方式 都会创建更新,区别在于:

  • 第一种方式,不需考虑更新前的状态,直接将状态num修改为3
  • 第二种方式,需要基于 更新前的状态 计算新状态

由于第二种方式的存在,更新之间可能有连续性。所以 expirationTime算法 计算出一个优先级后,组件render时实际参与更新当前状态的值的是:

计算出的优先级对应更新 + 与该优先级相关的其他优先级对应更新 这些相互关联,有连续性的更新被称为一个批次(batch)。expirationTime算法计算 批次 的方式也简单粗暴:优先级大于某个值(priorityOfBatch)的更新都会划为同一批次。

const isUpdateIncludedInBatch = priorityOfUpdate >= priorityOfBatch;

expirationTime算法保证了render异步可中断、且永远是最高优先级的更新先被处理。

两个阶段的拆分

除了Fiber 工作单元的拆分,两阶段的拆分也是一个非常重要的改造,在此之前都是一边Diff一边提交的。先来看看这两者的区别:

  • 协调阶段 可以认为是 Diff 阶段, 这个阶段可以被中断, 这个阶段会找出所有节点变更,例如节点新增、删除、属性变更等等, 这些变更React 称之为'副作用(Effect)',在协调阶段如果时间片用完,React就会选择让出控制权。因为协调阶段执行的工作不会导致任何用户可见的变更,所以在这个阶段让出控制权不会有什么问题。

  • 提交阶段 将上一个阶段计算出来的需要处理的**副作用(Effects)**一次性执行了。这个阶段必须同步执行,不能被打断

状态更新的时序

在 React 得到控制权后,应该优先处理高优先级的任务。也就是说正在处理的任务可能会被中断,在恢复时会让位给高优先级任务,原本中断的任务可能会被放弃或者重做。但是如果不按顺序执行任务,可能会导致前后的状态不一致。 比如低优先级任务将 a 设置为0,而高优先级任务将 a 递增1, 两个任务的执行顺序会影响最终的渲染结果。因此要让高优先级任务插队, 首先要保证状态更新的时序。

解决办法是: 所有更新任务按照顺序插入一个队列, 状态必须按照插入顺序进行计算,但任务可以按优先级顺序执行,例如:

红色表示高优先级任务。要计算它的状态必须基于前序任务计算出来的状态, 从而保证状态的最终一致性:

最终红色的高优先级任务 C 执行时的状态值是a=5,b=3. 在恢复控制权时,会按照优先级先执行 C, 前面的A、 B暂时跳过,虽然A、 B任务暂时跳过,但是会执行他们的状态。

上面被跳过任务不会被移除,在执行完高优先级任务后它们还是会被执行的。因为不同的更新任务影响的节点树范围可能是不一样的,举个例子 a、b 可能会影响 Foo 组件树,而 c 会影响 Bar 组件树。所以为了保证视图的最终一致性, 所有更新任务都要被执行。

  1. 首先 C 先被执行,它更新了 Foo 组件
  2. 接着执行 A 任务,它更新了Foo 和 Bar 组件,由于 C 已经以最终状态a=5, b=3更新了Foo组件,这里可以做一下性能优化,直接复用C的更新结果, 不必触发重新渲染。因此 A 仅需更新 Bar 组件即可。
  3. 接着执行 B,同理可以复用 Foo 更新结果。

道理讲起来都很简单,React Fiber 实际上非常复杂,不管执行的过程怎样拆分、以什么顺序执行,最重要的是保证状态的一致性和视图的一致性

react Fiber 出现以后让 react 有了可中断更新的能力,在 react 17 以前 react 依然是同步更新的(react 17有个实验版本,通过ReactDOM.createRoot(rootNode).render()创建的应用是并发更新),也就是协调过程不可中断,表现和 react 16之前一样。

整体流程

总结

react的组件架构是由一个个fiber组成的树组成,他的工作流程就是遍历fiber tree去执行每一个工作单元。分为协调阶段(深度遍历并diff产生新树、执行hooks链表并收集effect并链成链表)和提交阶段(处理effect链表,执行完毕切换渲染树)

fiber有新旧两棵树,一个是current fiber,是已经渲染在界面上的。一个是work fiber,由当前的更新触发而在内存中构建的。构建完成,work fiber就会替换cur fiber,然后经过提交阶段完成更新,在dom操作完成后渲染到界面上

一个页面就是一个fiber,这个页面的child就是render函数中的组件或者element,都会有他们自己的sibling,child,return(父级),如果是hook组件会在该fiber中的memoizedState属性保存它自己的hooks链表,在协调阶段通过执行hooks链表得到effect链表。协调阶段时,requestIdleCallback在主线程的空闲期执行低优先级的任务,requestAnimationFrame执行高优先级任务,requestIdleCallback执行完一个fiber的更新后,若下一个任务执行时间超过了deathLine,或者突然插入更高优先级的任务,则执行中断,保存当前结果,修改fiber node 的tag标记,设置为pending状态,恢复任务执行时,检查tag是被中断的任务,会接着继续做任务或者重做。当全部完成时进入提交阶段在提交阶段(不能被打断、同步、遍历)执行effect链表、调度Effect、操作DOM、执行周期函数,完成切换、渲染。

参考文章