浅析react Fiber架构中的异步渲染原理

  熟悉 react 的朋友都知道,在 react 中有个核心的算法,叫 diff 算法。web 界面由 dom 树组成,不同的 dom 树会渲染出不同的界面。react 使用 virtual dom 来表示 dom 树,而 diff 算法就是用于比较 virtual dom 树的区别,并更新界面需要更新的部分。

  diff 算法和 virtual dom 的完美结合的过程被称为 reconciler,这可是 react 攻城拔寨的绝对利器。有了 reconciler,开发者可以脱身操作真实的 dom 树,只需要向 react 描述界面的状态,而 react 会帮助你高效的完成真正 dom 操作。

  在 react16 之前的 reconciler 叫 stack reconciler,fiber 是 react 新的 reconciler,这次更新到 fiber 架构是一次重量级的核心架构的替换,react 为了完成这次替换已经准备了两三年的时间了。

  那么 fiber 究竟有什么好的呢?Fiber为何出现呢?

  不知道大家有没有遇到过这样的情况,点击一个页面的按钮时感觉到页面没有任何的反应,让你怀疑电脑是不是死机了,然后你快速切出浏览器,发现电脑并没有死机,于是再切回浏览器,这时候才发现页面终于更新了。为什么会出现这种情况?在多数情况下,可能是因为浏览器忙着执行相关的 js 代码,导致浏览器主线程没有及时响应用户的操作或者没有及时更新界面。本着顾客是上帝的原则,作为一名优秀的开发者,怎么能够允许出现这种情况降低用户的体验呢。因此 react 团队引入了异步渲染这个概念,而采用的 fiber 架构可以实现这种异步渲染的方式。

  原先的 stack reconciler 像是一个递归执行的函数,从父组件调用子组件的 reconciler 过程就是一个递归执行的过程,这也是为什么被称为 stack reconciler 的原因。当我们调用 setState 的时候,react 从根节点开始遍历,找出所有的不同,而对于特别庞大的 dom 树来说,这个递归遍历的过程会消耗特别长的时间。在这个期间,任何交互和渲染都会被阻塞,这样就给用户一种“死机”的感觉。

  fiber 的出现解决了这个问题,它把 reconciler 的过程拆分成了一个个的小任务,并在完成了小任务之后暂停执行 js 代码,然后检查是否有需要更新的内容和需要响应的事件,做出相应的处理后再继续执行 js 代码。这样就给了用户一种应用一直在运行的感觉,提高了用户的体验。

一、React 15 存在的问题

  在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象。

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

  针对这一问题,React 团队从框架层面对 web 页面的运行机制做了优化,得到很好的效果。

二、解题思路

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

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

  官方的解释是这样的:

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

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

三、Fiber 如何做到异步渲染

1、异步渲染设计原理

  在做显示方面的工作时,经常会听到一个目标叫 60 帧,这表示的是画面的更新频率,也就是画面每秒钟更新 60 次。这是因为在 60 帧的更新频率下,页面在人眼中显得流畅,无明显卡顿。每秒钟更新 60 次也就是每 16ms 需要更新一次页面,如果更新页面消耗的时间不到 16ms,那么在下一次更新时机来到之前会剩下一点时间执行其他的任务,只要保证及时在 16ms 的间隔下更新界面就完全不会影响到页面的流畅程度。

  Fiber 的核心正是利用了 60 帧原则,实现了一个基于优先级和 requestIdleCallback 的循环任务调度算法。

  requestIdleCallback 是浏览器提供的一个 api,可以让浏览器在空闲的时候执行回调,在回调参数中可以获取到当前帧剩余的时间,fiber 利用了这个参数,判断当前剩下的时间是否足够继续执行任务,如果足够则继续执行,否则暂停任务,并调用 requestIdleCallback 通知浏览器空闲的时候继续执行当前的任务。

  详见之前博客:浅析requestIdleCallback

function fiber(剩余时间) {
  if (剩余时间 > 任务所需时间) {
    做任务;
  } else {
    requestIdleCallback(fiber);
  }
}

2、fiber 还会为不同的任务设置不同的优先级

  高优先级任务是需要马上展示到页面上的,比如你正在输入框中输入文字,你肯定希望你的手指在键盘上敲下每一个按键时,输入框能立马做出反馈,这样你才能知道你的输入是否正确,是否有效。

  低优先级的任务则是像从服务器传来了一些数据,这个时候需要更新页面,比如这篇文章喜欢的人数+1 或是评论+1,这并不是那么紧急的更新,延迟 100-200ms 并不会有多大差别,完全可以在后面进行处理。

  fiber 会根据任务优先级来动态调整任务调度,优先完成高优先级的任务。

{ 
 Synchronous: 1, // 同步任务,优先级最高
 Task: 2, // 当前调度正执行的任务
 Animation 3, // 动画
 High: 4, // 高优先级
 Low: 5, // 低优先级
 Offscreen: 6, // 当前屏幕外的更新,优先级最低
}

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

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

  这次改动最大的当属 Reconciler 层了,React 团队也给它起了个新的名字,叫 Fiber Reconciler。这就引入另一个关键词:Fiber。

  在 fiber 架构中,有一种数据结构,它的名字就叫做 fiber,这也是为什么新的 reconciler 叫做 fiber 的原因。

  Fiber 其实指的就是这种数据结构,它可以用一个纯 JS 对象来表示:这个对象的属性中比较重要的有 stateNode、tag、return、child、sibling 和 alternate。

const Fiber = {
  stateNode  // 节点实例
  tag // 标记任务的进度
  return // 父节点
  child // 子节点
  sibling // 兄弟节点
  alternate // 变化记录
  .....
};

  我们可以看出 fiber 基于链表结构,拥有一个个指针,指向它的父节点子节点和兄弟节点,在 diff 的过程中,依照节点连接的关系进行遍历。

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

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

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

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

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

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

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

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

5、fiber 可能存在的问题:

  在 fiber 中,更新是分阶段的,具体分为两个阶段,首先是 reconciliation 的阶段,这个阶段在计算前后 dom 树的差异,然后是 commit 的阶段,这个阶段将把更新渲染到页面上。第一个阶段是可以打断的,因为这个阶段耗时可能会很长,因此需要暂停下来去执行其他更高优先级的任务,第二个阶段则不会被打断,会一口气把更新渲染到页面上。

  由于 reconciliation 的阶段会被打断,可能会导致 commit 前的这些生命周期函数多次执行。react 官方目前已经把 componentWillMount、componentWillReceiveProps 和 componetWillUpdate 标记为 unsafe,并使用新的生命周期函数 getDerivedStateFromProps 和 getSnapshotBeforeUpdate 进行替换。

  还有一个问题是饥饿问题,意思是如果高优先级的任务一直插入,导致低优先级的任务无法得到机会执行,这被称为饥饿问题。对于这个问题官方提出的解决方案是尽量复用已经完成的操作来缓解。相信官方也正在努力提出更好的方法去解决这个问题。

四、Fiber 树

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

  Fiber 树在首次渲染的时候会一次生成。在后续需要 Diff 的时候,会根据已有树和最新 Virtual DOM 的信息,生成一棵新的树。

  这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程:

 

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

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

参考文章:

React Fiber 原理介绍

posted @ 2021-03-31 19:02  古兰精  阅读(1102)  评论(1编辑  收藏  举报