React Fiber原理解析

9,984 阅读14分钟

前言

今天,骑着我心爱的小电驴,心事重重的走在大街上,突然,前面出现一个饿了么外卖小哥,在外卖箱的右下角上赫然写着Carbon Fiber(碳纤维),原来Fiber还可以这么用!!! 感觉就像打通了我的任督二脉一样,瞬间对Fiber这个单词的理解有了飞跃性的提升!一个保温箱都用上了Carbon Fiber,看来确实是下了一番功夫!

React团队重写了React 的核心算法---reconciliation,一般将之前的算法叫stack reconciliation,现在的叫fiber reconciliation

React Fiber架构面世之后,引起了不小的轰动。React团队为什么要重写React架构,而Fiber又是什么?下面我们就一起走进Fiber

一、React V15.x 版本存在的问题

在页面元素很多,且需要频繁刷新的场景下,React V15.x 会出现掉帧的现象。下面是一个非常经典的例子:

参考链接:claudiopro.github.io/react-fiber…

从上图可以看出明显的掉帧现象。原因是大量的同步计算任务阻塞了浏览器的UI渲染。因为在默认情况下,JS运算、页面布局、页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果JS运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState更新页面的时候,React会遍历应用的所有节点,计算出差异,然后再更新UI。整个过程是不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过16ms,就容易出现掉帧的现象。

Dom-Diff时也是如此递归遍历对比,且存在两个非常影响性能的问题:

  • 树节点庞大时,会导致递归调用执行栈越来越深
  • 不能中断执行,页面会等待递归执行完成才重新渲染

为了解决这一问题,React团队从框架层面对web页面的运行机制做了优化,并且得到了很好的效果。效果图如下:

二、如何解决

解决主线程长时间被 JS 运算占用这一问题的基本思路,是将运算切割为多个步骤,分批完成。也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。

React将任务分成小片,在一小片段的时间内运行这些分片任务,让主线程做优先级更高的事情,如果有任何待处理的事情,就回来完成工作。一个Fiber就是一个工作单元, React 的一个核心概念是 UI 是数据的投影 ,组件的本质可以看作输入数据,输出UI的描述信息(虚拟DOM树),即:

ui = f(data)

也就是说,渲染一个 React app,其实是在调用一个函数,函数本身会调用其它函数,形成调用栈,递归调用导致的调用栈我们本身无法控制, 只能一次执行完成。而 Fiber 就是为了解决这个痛点,可以去按需要打断调用栈,手动控制 stack frame——就这点来说,Fiber 可以理解为 virtual stack frame

旧版 React通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback这一 API。官方的解释是这样的:

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台和低优先级的任务,而且不会对像动画和输入响应等用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

参考文档: developer.mozilla.org/zh-CN/docs/…

Fiber的拆分

既然要将任务拆分为小任务,得有拆分的方案。

Fiber的拆分方案就是按照虚拟DOM拆分,因为fiber tree是根据Virtual DOM tree 构建出来的,结构上是一样的,只是节点携带的信息不一样。

因此,每个组件实例和每个DOM节点的抽象表示都是一个工作单元,在工作循环中,每次处理一个fiber,处理完一个就会判断是否有高优先级的任务或者剩余时间是否充足,可以继续处理或者挂起或者完成工作循环。

Fiber reconciliation的工作循环

  1. 找到根节点优先级最高的workInProgress tree,取其待处理的节点(代表组件或DOM节点)
  2. 检查当前节点是否需要更新,不需要的话,直接到4
  3. 标记一下(打个tag),更新自己(组件更新propscontext等,DOM节点记下DOM change),进行reconcileChildren并返回workInProgress.child
  4. 不存在workInProgress.child,证明是叶子节点,向上收集effect
  5. child或者sibling当做nextUnitWork,进入下一个工作循环。如果回到了workInProgress tree的根节点,则工作循环结束
  6. 进入commit阶段

Fiber工作阶段phase

  1. diff renderreconciliation 主要是构建workInProgress tree,其实是diff过程
  2. complete diffProperties,标记tag,收集effect
  3. commit 提交阶段,应用更新

workInProgress 双缓冲池技术

Fiber调度算法采取了双缓冲池算法FiberRoot底下的所有节点,都会在算法过程中,尝试创建自己的“镜像”。

workInProgress treereconcile过程中从fiber tree建立的当前进度快照,所有工作都是在这颗树上进行,用于计算更新,完成reconciliation过程。

workInProgress.alternate = current;
current.alternate = workInProgress;

他和当前的fiber通过alternate进行关联,在构建workInProgress 时,会取current.alternate,存在则复用,不存在则创建。这样做能够复用内部对象(fiber),节省内存分配、GC的时间开销。

workInProgress tree 构造完毕,得到的就是新的fiber tree,当进入commit阶段就把current指向了workInProgress

root.current = finishedWork;

核心思想是双缓池技术(double buffering pooling technique),因为需要做 diff 的话,起码是要有两棵树进行对比。通过这种方式,可以把树的总体数量限制在2,节点、节点属性都是延迟创建的,最大限度地避免内存使用量因算法过程而不断增长。

双缓冲的两棵树 FiberNode Tree 角色互换,原来的 workInProgress 转正。

双缓冲更新策略

  • 将每次渲染完后的fiber树赋值给currentRoot
  • 第一次更新时将rooterFiberalternate指向上一次渲染好的currentRoot
  • 第二次之后的更新将workInProgressRoot指向currentRoot.alternate,然后将当前的workInProgressRoot.alternate指向上一次渲染好的currentRoot
  • ...
  • 进而达到复用fiber对象树

有了解题思路后,我们再来看看 React 具体是怎么做的

三、解决方法

React 框架内部的运作可以分为 3 层:

  • Virtual DOM 层,描述页面长什么样。
  • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM ReactNative

因此Virtual DOMDom diffFiberRenderer等之间并不是独立的概念!而是相互关联,相辅相成的!

其中Reconciler层改动最大,React团队也给它新起了个名字:Fiber Reconciler。也就是Fiber

Fiber 其实指的是一种数据结构,它可以用一个纯 JS 对象来表示:

const fiber = {
    stateNode,    // 节点实例
    child,        // 子节点
    sibling,      // 兄弟节点
    return,       // 父节点
}

为了加以区分,以前的Reconciler 被命名为Stack ReconcilerStack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:

Fiber Reconciler每执行一段时间,都会将控制权交回给浏览器,可以分段执行:

为了达到这种效果,就需要有一个调度器(Scheduler)来进行任务分配。任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如 diff)的执行从而更快的生效。

Fiber Reconciler在执行过程中,会分为2个阶段:

  • 阶段一,生成Fiber树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

Fiber的特点:

  • 暂停工作,并在之后可以返回再次开始
  • 可以为不同类型的工作设置优先级
  • 复用之前已经完成的工作
  • 终止已经不再需要的工作

四、Fiber

Fiber Reconciler在阶段一时进行Diff计算的时候,会生成一棵Fiber树。这棵树是在Virtual DOM树的基础上增加额外的信息来生成的,它本质上来说是一个链表。

Fiber树在首次渲染的时候会一次过生成。在后续需要Diff的时候,会根据已有树和最新Virtual DOM的信息,生成一棵新的树,这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程:

如果过程中有优先级更高的任务需要进行,则Fiber Reconciler会丢弃正在生成的树,在空闲的时候再重新执行一遍。

在构造Fiber树的过程中,Fiber Reconciler会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。

Fiber执行阶段

每次渲染有两个阶段:Reconciliation(协调render)阶段和Commit(提交)阶段

  • 协调render阶段:可以认为是Diff阶段,这个阶段可以被中断,这个阶段会找出所有节点变更,例如节点增删改等等,这些变更在React中称为Effect(副作用)
  • 提交阶段:将上一个阶段计算出来的需要处理的副作用一次性执行。这个阶段不能中断,必须同步一次性执行完

Fiber小结

  • 我们可以通过某些调度策略合理分配CPU资源,从而提高用户的响应速度
  • 通过Fiber架构,让自己的Reconciliation过程变得可被中断,适时地让出CPU执行权,可以让浏览器及时地响应用户的交互

五、总结

我们从React V15 存在的问题出发,介绍了React Fiber解决问题的思路,并介绍了Fiber Reconciler的工作流程。从Stack ReconcilerFiber Reconciler,源码层面将递归改为循环,从而解决了React V15 存在的问题。

六、源码简析

scheduler

React16.5 之后把scheduler单独发一个包了,就叫scheduler

scheduleCallbackWithExpirationTime

异步进行root任务调度就是通过这个方法来做的,这里最重要的就是调用了schedulerscheduleDeferredCallback方法(在scheduler包中是schedulework)传入的事回调函数performAsyncWork,以及一个包含timeout超时事件的对象

function scheduleCallbackWithExpirationTime(
  root: FiberRoot,
  expirationTime: ExpirationTime,
) {
  if (callbackExpirationTime !== NoWork) {
    // A callback is already scheduled. Check its expiration time (timeout).
    if (expirationTime > callbackExpirationTime) {
      // Existing callback has sufficient timeout. Exit.
      return
    } else {
      if (callbackID !== null) {
        // Existing callback has insufficient timeout. Cancel and schedule a
        // new one.
        cancelDeferredCallback(callbackID)
      }
    }
    // The request callback timer is already running. Don't start a new one.
  } else {
    startRequestCallbackTimer()
  }

  callbackExpirationTime = expirationTime
  const currentMs = now() - originalStartTimeMs
  const expirationTimeMs = expirationTimeToMs(expirationTime)
  const timeout = expirationTimeMs - currentMs
  // 最主要的就是调用了scheduler的scheduleDeferredCallback方法
  callbackID = scheduleDeferredCallback(performAsyncWork, { timeout })
}

scheduler.scheduleWork

创建一个调度节点newNode,并按照timeoutAt的顺序加入到CallbackNode链表,调用 ensureHostCallbackIsScheduled 这里面的 expirationTime 是调用时传入的timeoutAt加上当前时间形成的过期时间。

function unstable_scheduleCallback(callback, deprecated_options) {
  var startTime =
    currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime()

  var expirationTime
  if (
    typeof deprecated_options === 'object' &&
    deprecated_options !== null &&
    typeof deprecated_options.timeout === 'number'
  ) {
    // FIXME: Remove this branch once we lift expiration times out of React.
    expirationTime = startTime + deprecated_options.timeout
  } else {
    // 这里是以后把`expirationTime`从React中抽离出来之后的逻辑
  }

  var newNode = {
    callback,
    priorityLevel: currentPriorityLevel,
    expirationTime,
    next: null,
    previous: null,
  }

  // Insert the new callback into the list, ordered first by expiration, then
  // by insertion. So the new callback is inserted any other callback with
  // equal expiration.
  if (firstCallbackNode === null) {
    // This is the first callback in the list.
    firstCallbackNode = newNode.next = newNode.previous = newNode
    // 调用ensureHostCallbackIsScheduled
    ensureHostCallbackIsScheduled()
  } else {
    var next = null
    var node = firstCallbackNode
    do {
      if (node.expirationTime > expirationTime) {
        // The new callback expires before this one.
        next = node
        break
      }
      node = node.next
    } while (node !== firstCallbackNode)

    if (next === null) {
      // No callback with a later expiration was found, which means the new
      // callback has the latest expiration in the list.
      next = firstCallbackNode
    } else if (next === firstCallbackNode) {
      // The new callback has the earliest expiration in the entire list.
      firstCallbackNode = newNode
      ensureHostCallbackIsScheduled()
    }

    var previous = next.previous
    previous.next = next.previous = newNode
    newNode.next = next
    newNode.previous = previous
  }

  return newNode
}

ensureHostCallbackIsScheduled

如果已经在调用回调了,就return,因为本来就会继续调用下去,isExecutingCallbackflushWork的时候会被修改为true。如果isHostCallbackScheduledfalse,也就是还没开始调度,那么设为true,如果已经开始了,就直接取消,因为顺序可能变了。调用requestHostCallback开始调度这里

function ensureHostCallbackIsScheduled() {
  if (isExecutingCallback) {
    // Don't schedule work yet; wait until the next time we yield.
    return
  }
  // Schedule the host callback using the earliest expiration in the list.
  var expirationTime = firstCallbackNode.expirationTime
  if (!isHostCallbackScheduled) {
    isHostCallbackScheduled = true
  } else {
    // Cancel the existing host callback.
    cancelHostCallback()
  }
  requestHostCallback(flushWork, expirationTime)
}

cancelHostCallback = function() {
  scheduledHostCallback = null
  isMessageEventScheduled = false
  timeoutTime = -1
}

requestHostCallback

开始进入调度,设置调度的内容,用scheduledHostCallbacktimeoutTime这两个全局变量记录回调函数和对应的过期时间

调用requestAnimationFrameWithTimeout,其实就是调用requestAnimationFrame在加上设置了一个100ms的定时器,防止requestAnimationFrame太久不触发。

调用回调animationTick并设置isAnimationFrameScheduled全局变量为true

requestHostCallback = function(callback, absoluteTimeout) {
  scheduledHostCallback = callback
  timeoutTime = absoluteTimeout
  if (isFlushingHostCallback || absoluteTimeout < 0) {
    // Don't wait for the next frame. Continue working ASAP, in a new event.
    window.postMessage(messageKey, '*')
  } else if (!isAnimationFrameScheduled) {
    isAnimationFrameScheduled = true
    requestAnimationFrameWithTimeout(animationTick)
  }
}

模拟 requestIdleCallback

因为requestIdleCallback这个API目前还处于草案阶段,所以浏览器实现率还不高,所以在这里React直接使用了polyfill的方案。

这个方案简单来说就是通过requestAnimationFrame在浏览器渲染一帧之前多做一些处理,然后通过postMessagemacro task(类似 setTimeout)中加入一个回调,因为接下去会进入浏览器渲染阶段,所以主线程是被block住的,等到主线程有空的时候回来调用

animationTick

只要scheduledHostCallback还在就继续调要requestAnimationFrameWithTimeout因为这一帧渲染完了可能队列还没清空,本身也是要进入再次调用的,这边就省去了requestHostCallback在次调用的必要性

接下去一段代码是用来计算相隔的requestAnimationFrame的时差的,这个时差如果连续两次都小于当前的activeFrameTime,说明平台的帧率是很高的,这种情况下会动态的缩小帧时间。

最后更新frameDeadline,然后如果没有触发idleTick则发送消息

var animationTick = function(rafTime) {
  if (scheduledHostCallback !== null) {
    requestAnimationFrameWithTimeout(animationTick)
  } else {
    isAnimationFrameScheduled = false
    return
  }

  var nextFrameTime = rafTime - frameDeadline + activeFrameTime
  if (nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) {
    if (nextFrameTime < 8) {
      nextFrameTime = 8
    }
    activeFrameTime =
      nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime
  } else {
    previousFrameTime = nextFrameTime
  }
  frameDeadline = rafTime + activeFrameTime
  if (!isMessageEventScheduled) {
    isMessageEventScheduled = true
    window.postMessage(messageKey, '*')
  }
}

idleTick

首先判断postMessage是不是自己的,不是直接返回

清空scheduledHostCallbacktimeoutTime

获取当前时间,对比frameDeadline,查看是否已经超时了,如果超时了,判断一下任务callback的过期时间有没有到,如果没有到,则重新对这个callback进行一次调度,然后返回。如果到了,则设置didTimeouttrue

接下去就是调用callback了,这里设置isFlushingHostCallback全局变量为true代表正在执行。并且调用callback也就是flushWork并传入didTimeout

var idleTick = function(event) {
  if (event.source !== window || event.data !== messageKey) {
    return
  }

  isMessageEventScheduled = false

  var prevScheduledCallback = scheduledHostCallback
  var prevTimeoutTime = timeoutTime
  scheduledHostCallback = null
  timeoutTime = -1

  var currentTime = getCurrentTime()

  var didTimeout = false
  if (frameDeadline - currentTime <= 0) {
    if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
      didTimeout = true
    } else {
      if (!isAnimationFrameScheduled) {
        isAnimationFrameScheduled = true
        requestAnimationFrameWithTimeout(animationTick)
      }
      scheduledHostCallback = prevScheduledCallback
      timeoutTime = prevTimeoutTime
      return
    }
  }

  if (prevScheduledCallback !== null) {
    isFlushingHostCallback = true
    try {
      prevScheduledCallback(didTimeout)
    } finally {
      isFlushingHostCallback = false
    }
  }
}

flushWork

先设置isExecutingCallbacktrue,代表正在调用callback

设置deadlineObject.didTimeout,在 React 业务中可以用来判断任务是否超时

如果didTimeout,会一次从firstCallbackNode向后一直执行,知道第一个没过期的任务

如果没有超时,则依此执行第一个callback,直到帧时间结束为止

最后清理变量,如果任务没有执行完,则再次调用ensureHostCallbackIsScheduled进入调度

顺便把Immedia优先级的任务都调用一遍。

function flushWork(didTimeout) {
  isExecutingCallback = true
  deadlineObject.didTimeout = didTimeout
  try {
    if (didTimeout) {
      while (firstCallbackNode !== null) {
        var currentTime = getCurrentTime()
        if (firstCallbackNode.expirationTime <= currentTime) {
          do {
            flushFirstCallback()
          } while (
            firstCallbackNode !== null &&
            firstCallbackNode.expirationTime <= currentTime
          )
          continue
        }
        break
      }
    } else {
      if (firstCallbackNode !== null) {
        do {
          flushFirstCallback()
        } while (
          firstCallbackNode !== null &&
          getFrameDeadline() - getCurrentTime() > 0
        )
      }
    }
  } finally {
    isExecutingCallback = false
    if (firstCallbackNode !== null) {
      ensureHostCallbackIsScheduled()
    } else {
      isHostCallbackScheduled = false
    }
    flushImmediateWork()
  }
}

flushFirstCallback

它做的事情很简单

  • 如果当前队列中只有一个回调,清空队列
  • 调用回调并传入deadline对象,里面有timeRemaining方法通过frameDeadline - now()来判断是否帧时间已经到了
  • 如果回调有返回内容,把这个返回加入到回调队列

全局变量参考

isHostCallbackScheduled

是否已经开始调度了,在ensureHostCallbackIsScheduled设置为true,在结束执行callback之后设置为false

scheduledHostCallback

requestHostCallback设置,值一般为flushWork,代表下一个调度要做的事情

isMessageEventScheduled

是否已经发送调用idleTick的消息,在animationTick中设置为true

timeoutTime

表示过期任务的时间,在idleTick中发现第一个任务的时间已经过期的时候设置

isAnimationFrameScheduled

是否已经开始调用requestAnimationFrame

activeFrameTime

给一帧渲染用的时间,默认是 33,也就是 1 秒 30 帧

frameDeadline

记录当前帧的到期时间,他等于currentTime + activeFraeTime,也就是requestAnimationFrame回调传入的时间,加上一帧的时间。

isFlushingHostCallback

是否正在执行callback

参考文档:React Fiber工作原理和相关概念

附录:

1. React15存在哪些痛点?Fiber是什么?React16为什么需要引入Fiber?

  • 渲染和diff阶段一气呵成,节点树庞大时会导致页面卡死
  • Fiber并不神秘,只是将Virtual-DOM转变为一种链表结构
  • 链表结构配合requestIdleCallback可实现可中断可恢复的调度机制

2. React16下的虚拟DOM与React15相同

3. 如何实现Fiber的数据结构和遍历算法?

  • React中使用链表将Virtual DOM链接起来,每一个节点表示一个Fiber

4. 如何实现Fiber架构下可中断和可恢复的的任务调度?

借助requestIdleCallback交由浏览器在一帧渲染后的给出的空闲时间内实现指定数量跟新,批量更新可以直接跳过这个API,按之前的方式

5. 如何实现Fiber架构下的组件渲染和副作用收集提交?

  • 执行的收集顺序类似于二叉树的先序遍历
  • 完成的收集顺序类似于二叉树的后序遍历

6. 如何实现Fiber中的调和和双缓冲优化策略?

  • Fiber结构中增加一个alternate字段标识上一次渲染好的Fiber树,下次渲染时可复用

以上就是我对Fiber的粗浅理解,如有错误,欢迎指正~~~