React Fiber架构原理剖析

xiangzhihong重庆
English

一、概述

在 React 16 之前,VirtualDOM 的更新采用的是Stack架构实现的,也就是循环递归方式。不过,这种对比方式有明显的缺陷,就是一旦任务开始进行就无法中断,如果遇到应用中组件数量比较庞大,那么VirtualDOM 的层级就会比较深,带来的结果就是主线程被长期占用,进而阻塞渲染、造成卡顿现象。

为了避免出现卡顿等问题,我们必须保障在执行更新操作时计算时不能超过16ms,如果超过16ms,就需要先暂停,让给浏览器进行渲染,后续再继续执行更新计算。而Fiber架构就是为了支持“可中断渲染”而创建的。

在React中,Fiber使用了一种新的数据结构fiber tree,它可以把虚拟dom tree转换成一个链表,然后再执行遍历操作,而链表在执行遍历操作时是支持断点重启的,示意图如下。
image.png

二、Fiber架构

2.1 执行单元

官方介绍中,Fiber 被理解为是一种数据结构,但是我们也可以将它理解为是一个执行单元。

Fiber 可以理解为一个执行单元,每次执行完一个执行单元,React Fiber就会检查还剩多少时间,如果没有时间则将控制权让出去,然后由浏览器执行渲染操作。React Fiber 与浏览器的交互流程如下图。
image.png

可以看到,React 首先向浏览器请求调度,浏览器在执行完一帧后如果还有空闲时间,会去判断是否存在待执行任务,不存在就直接将控制权交给浏览器;如果存在就会执行对应的任务,执行完一个新的任务单元之后会继续判断是否还有时间,有时间且有待执行任务则会继续执行下一个任务,否则将控制权交给浏览器执行渲染,这个流程是循环进行的。

所以,我们可以将Fiber 理解为一个执行单元,并且这个执行单元必须是一次完成的,不能出现暂停。并且,这个小的执行单元在执行完后计算之后,可以移交控制权给浏览器去响应用户,从而提升了渲染的效率。

2.2 数据结构

在官方的文档中,Fiber 被解释为是一种数据结构,即链表结构。在链表结构中,每个 Virtual DOM 都可以表示为一个 fiber,如下图所示。
image.png
通常,一个 fiber包括了 child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,React Fiber 机制的实现,就是依赖于上面的数据结构。

2.3 Fiber链表结构

通过介绍,我们知道Fiber使用的是链表结构,准确的说是单链表树结构,详见ReactFiber.js源码。为了放便理解 Fiber 的遍历过程,下面我们就看下Fiber链表结构。

image.png

在上面的例子中,每一个单元都包含了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()。并且,插入节点单元时需要考虑是否已经存在节点,如果不存在直接将firstUpdate、lastUpdate指向此节点即可。更新队列是遍历这个链表,根据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
  }
}

最后,我们写一个测试的用例:实例化一个队列,向其中加入很多节点,再更新这个队列。

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 }

2.4 Fiber节点

Fiber 框架的拆分单位是 fiber(fiber tree上的一个节点),实际上拆分的节点就是虚拟DOM的节点,我们需要根据虚拟dom去生成 fiber tree。 Fiber节点的数据结构如下:

{
    type: any,   //对于类组件,它指向构造函数;对于DOM元素,它指定HTML tag
    key: null | string,  //唯一标识符
    stateNode: any,  //保存对组件的类实例,DOM节点或与fiber节点关联的其他React元素类型的引用
    child: Fiber | null, //大儿子
    sibling: Fiber | null, //下一个兄弟
    return: Fiber | null, //父节点
    tag: WorkTag, //定义fiber操作的类型, 详见https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactWorkTags.js
    nextEffect: Fiber | null, //指向下一个节点的指针
    updateQueue: mixed, //用于状态更新,回调函数,DOM更新的队列
    memoizedState: any, //用于创建输出的fiber状态
    pendingProps: any, //已从React元素中的新数据更新,并且需要应用于子组件或DOM元素的props
    memoizedProps: any, //在前一次渲染期间用于创建输出的props
    // ……     
}

最终, 所有的fiber 节点通过以下属性:child,sibling 和 return来构成一个树链表。
其他的属性还有memoizedState(创建输出的 fiber 的状态)、pendingProps(将要改变的 props )、memoizedProps(上次渲染创建输出的 props )、pendingWorkPriority(定义 fiber 工作优先级)等等就不在过多的介绍了。

2.5 API

2.5.1 requestAnimationFrame

requestAnimationFrame是浏览器提供的绘制动画的 API ,它要求浏览器在下次重绘之前(即下一帧)调用指定的回调函数以更新动画。

例如,使用requestAnimationFrame实现正方形的宽度加1px,直到宽度达到100px停止,代码如下。

<body>
  <div id="div" class="progress-bar "></div>
  <button id="start">开始动画</button>
</body>

<script>
  let btn = document.getElementById('start')
  let div = document.getElementById('div')
  let start = 0
  let allInterval = []

  const progress = () => {
    div.style.width = div.offsetWidth + 1 + 'px'
    div.innerHTML = (div.offsetWidth) + '%'
    if (div.offsetWidth < 100) {
      let current = Date.now()
      allInterval.push(current - start)
      start = current
      requestAnimationFrame(progress)
    }  
  }

  btn.addEventListener('click', () => {
    div.style.width = 0
    let currrent = Date.now()
    start = currrent
    requestAnimationFrame(progress)
  })
</script>

运行上面的代码,就可以看到浏览器会在每一帧运行结束后,将div的宽度加1px,直到100px为止。

2.5.2 requestIdleCallback

requestIdleCallback 也是 Fiber 的基础 API 。requestIdleCallback能使开发者在主事件循环上执行后台和低优先级的工作,而不会影响延迟关键事件,如动画和输入响应。正常帧任务完成后没超过16ms,说明有多余的空闲时间,此时就会执行requestIdleCallback里注册的任务。

具体的执行流程是,开发者采用requestIdleCallback方法注册对应的任务,告知浏览器任务的优先级不高,如果每一帧内存在空闲时间,就可以执行注册的这个任务。另外,开发者是可以传入timeout参数去定义超时时间的,如果到了超时时间,那么浏览器必须立即执行,使用方法如下:

window.requestIdleCallback(callback, { timeout: 1000 })。

浏览器执行完方法后,如果没有剩余时间了,或者已经没有下一个可执行的任务了,React应该归还控制权,并同样使用requestIdleCallback去申请下一个时间片。具体的流程如下图:
image.png

其中,requestIdleCallback的callback中会接收到默认参数 deadline ,其中包含了以下两个属性:

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

三、Fiber执行流程

Fiber的执行流程总体可以分为渲染和调度两个阶段,即render阶段和commit 阶段。其中,render 阶段是可中断的,需要找出所有节点的变更;而commit 阶段是不可中断的,只会执行操作。

3.1 render阶段

此阶段的主要任务就是找出所有节点产生的变更,如节点的新增、删除、属性变更等。这些变更, React 统称为副作用,此阶段会构建一棵Fiber tree,以虚拟Dom节点的维度对任务进行拆分,即一个虚拟Dom节点对应一个任务,最后产出的结果是副作用列表(effect list)。

3.1.1 遍历流程

在此阶段,React Fiber会将虚拟DOM树转化为Fiber tree,这个Fiber tree是由节点构成的,每个节点都有child、sibling、return属性,遍历Fiber tree时采用的是后序遍历方法,遍历的流程如下:
从顶点开始遍历;
如果有大儿子,先遍历大儿子;如果没有大儿子,则表示遍历完成;
大儿子: a. 如果有弟弟,则返回弟弟,跳到2 b. 如果没有弟弟,则返回父节点,并标志完成父节点遍历,跳到2 d. 如果没有父节点则标志遍历结束

下面是后序遍历的示意图:

image.png

此时,树结构的定义如下:

const A1 = { type: 'div', key: 'A1' }
const B1 = { type: 'div', key: 'B1', return: A1 }
const B2 = { type: 'div', key: 'B2', return: A1 }
const C1 = { type: 'div', key: 'C1', return: B1 }
const C2 = { type: 'div', key: 'C2', return: B1 }
const C3 = { type: 'div', key: 'C3', return: B2 }
const C4 = { type: 'div', key: 'C4', return: B2 }

A1.child = B1
B1.sibling = B2
B1.child = C1
C1.sibling = C2
B2.child = C3
C3.sibling = C4

module.exports = A1

3.1.2 收集effect list

接下来,就是收集节点产生的变更,并将结果转化成一个effect list,步骤如下:

  1. 如果当前节点需要更新,则打tag更新当前节点状态(props, state, context等);
  2. 为每个子节点创建fiber。如果没有产生child fiber,则结束该节点,把effect list归并到return,把此节点的sibling节点作为下一个遍历节点;否则把child节点作为下一个遍历节点;
  3. 如果有剩余时间,则开始下一个节点,否则等下一次主线程空闲再开始下一个节点;
  4. 如果没有下一个节点了,进入pendingCommit状态,此时effect list收集完毕,结束。

如果用代码来实现的话,首先需要遍历子虚拟DOM元素数组,为每个虚拟DOM元素创建子fiber。

const reconcileChildren = (currentFiber, newChildren) => {
  let newChildIndex = 0
  let prevSibling // 上一个子fiber

  // 遍历子虚拟DOM元素数组,为每个虚拟DOM元素创建子fiber
  while (newChildIndex < newChildren.length) {
    let newChild = newChildren[newChildIndex]
    let tag
    // 打tag,定义 fiber类型
    if (newChild.type === ELEMENT_TEXT) { // 这是文本节点
      tag = TAG_TEXT
    } else if (typeof newChild.type === 'string') {  // 如果type是字符串,则是原生DOM节点
      tag = TAG_HOST
    }
    let newFiber = {
      tag,
      type: newChild.type,
      props: newChild.props,
      stateNode: null, // 还未创建DOM元素
      return: currentFiber, // 父亲fiber
      effectTag: INSERT, // 副作用标识,包括新增、删除、更新
      nextEffect: null, // 指向下一个fiber,effect list通过nextEffect指针进行连接
    }
    if (newFiber) {
      if (newChildIndex === 0) {
        currentFiber.child = newFiber // child为大儿子
      } else {
        prevSibling.sibling = newFiber // 让大儿子的sibling指向二儿子
      }
      prevSibling = newFiber
    }
    newChildIndex++
  }
}

该方法会收集 fiber 节点下所有的副作用,并组成effect list。每个 fiber 有两个属性:

  • firstEffect:指向第一个有副作用的子fiber。
  • lastEffect:指向最后一个有副作用的子fiber。

而我们需要收集的就是中间nextEffect,最终形成一个单链表。

// 在完成的时候要收集有副作用的fiber,组成effect list
const completeUnitOfWork = (currentFiber) => {
  // 后续遍历,儿子们完成之后,自己才能完成。最后会得到以上图中的链条结构。
  let returnFiber = currentFiber.return
  if (returnFiber) {
    // 如果父亲fiber的firstEffect没有值,则将其指向当前fiber的firstEffect
    if (!returnFiber.firstEffect) {
      returnFiber.firstEffect = currentFiber.firstEffect
    }
    // 如果当前fiber的lastEffect有值
    if (currentFiber.lastEffect) {
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber.firstEffect
      }
      returnFiber.lastEffect = currentFiber.lastEffect
    }
    const effectTag = currentFiber.effectTag
    if (effectTag) { // 说明有副作用
      // 每个fiber有两个属性:
      // 1)firstEffect:指向第一个有副作用的子fiber
      // 2)lastEffect:指向最后一个有副作用的子fiber
      // 中间的使用nextEffect做成一个单链表
      if (returnFiber.lastEffect) {
        returnFiber.lastEffect.nextEffect = currentFiber
      } else {
        returnFiber.firstEffect = currentFiber
      }
      returnFiber.lastEffect = currentFiber
    }
  }
}

最后,再定义一个递归函数,从根节点出发,把全部的 fiber 节点遍历一遍,最终产出一个effect list。

const performUnitOfWork = (currentFiber) => {
  beginWork(currentFiber)
  if (currentFiber.child) {
    return currentFiber.child
  }
  while (currentFiber) {
    completeUnitOfWork(currentFiber)  
    if (currentFiber.sibling) {  
      return currentFiber.sibling
    }
    currentFiber = currentFiber.return 
  }
}

3.2 commit阶段

commit 阶段需要将上阶段计算出来的需要处理的副作用一次性执行,此阶段不能暂停,否则会出现UI更新不连续的现象。此阶段需要根据effect list,将所有更新都 commit 到DOM树上。

3.2.1 根据effect list 更新视图

此阶段,根据一个 fiber 的effect list列表去更新视图,此次只列举了新增节点、删除节点、更新节点的三种操作 。

const commitWork = currentFiber => {
  if (!currentFiber) return
  let returnFiber = currentFiber.return
  let returnDOM = returnFiber.stateNode // 父节点元素
  if (currentFiber.effectTag === INSERT) {  // 如果当前fiber的effectTag标识位INSERT,则代表其是需要插入的节点
    returnDOM.appendChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === DELETE) {  // 如果当前fiber的effectTag标识位DELETE,则代表其是需要删除的节点
    returnDOM.removeChild(currentFiber.stateNode)
  } else if (currentFiber.effectTag === UPDATE) {  // 如果当前fiber的effectTag标识位UPDATE,则代表其是需要更新的节点
    if (currentFiber.type === ELEMENT_TEXT) {
      if (currentFiber.alternate.props.text !== currentFiber.props.text) {
        currentFiber.stateNode.textContent = currentFiber.props.text
      }
    }
  }
  currentFiber.effectTag = null
}

写一个递归函数,从根节点出发,根据effect list完成全部更新。

/**
* 根据一个 fiber 的 effect list 更新视图
*/
const commitRoot = () => {
  let currentFiber = workInProgressRoot.firstEffect
  while (currentFiber) {
    commitWork(currentFiber)
    currentFiber = currentFiber.nextEffect
  }
  currentRoot = workInProgressRoot // 把当前渲染成功的根fiber赋给currentRoot
  workInProgressRoot = null
}

3.2.2 视图更新

接下来,就是循环执行工作,当计算完成每个 fiber 的effect list后,调用 commitRoot 完成视图更新。

const workloop = (deadline) => {
  let shouldYield = false // 是否需要让出控制权
  while (nextUnitOfWork && !shouldYield) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
    shouldYield = deadline.timeRemaining() < 1 // 如果执行完任务后,剩余时间小于1ms,则需要让出控制权给浏览器
  }
  if (!nextUnitOfWork && workInProgressRoot) {
    console.log('render阶段结束')
    commitRoot() // 没有下一个任务了,根据effect list结果批量更新视图
  }
  // 请求浏览器进行再次调度
  requestIdleCallback(workloop, { timeout: 1000 })
}

到此,根据收集到的变更信息完成了视图的刷新操作,Fiber的整个刷新流程也就实现了。

四、总结

相比传统的Stack架构,Fiber 将工作划分为多个工作单元,每个工作单元在执行完成后依据剩余时间决定是否让出控制权给浏览器执行渲染。 并且它设置每个工作单元的优先级,暂停、重用和中止工作单元。 每个Fiber节点都是fiber tree上的一个节点,通过子、兄弟和返回引用连接,形成一个完整的fiber tree。

阅读 3.4k

著有《React Native移动开发实战》1,2、《Kotlin入门与实战》《Weex跨平台开发实战》、《Flutter跨平台...

5.1k 声望
15.2k 粉丝
0 条评论

著有《React Native移动开发实战》1,2、《Kotlin入门与实战》《Weex跨平台开发实战》、《Flutter跨平台...

5.1k 声望
15.2k 粉丝
宣传栏