Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

React Fiber 架构 #24

Open
SampsonKY opened this issue Oct 14, 2020 · 1 comment
Open

React Fiber 架构 #24

SampsonKY opened this issue Oct 14, 2020 · 1 comment
Assignees
Labels

Comments

@SampsonKY
Copy link
Owner

React 的理念

React 的理念

我们认为,React 是用 JavaScript 构建快速响应的大型 Web 应用程序的首选方式。它在 Facebook 和 Instagram 上表现优秀。 ——官网

快速响应即:速度快,响应自然

响应快

由于语法的灵活,在编译时无法区分可能变化的部分。所以在运行时,React需要遍历每个元素,判断其数据是否更新。基于以上原因,相比于VueAngular,缺少编译时优化手段的React为了速度快需要在运行时做出更多努力。

  • 使用PureComponentReact.memo构建组件
  • 使用shouldComponentUpdate生命周期钩子
  • 渲染列表时使用key
  • 使用useCallbackuseMemo缓存函数和变量
  • ……

由开发者来显式的告诉React哪些组件不需要重复计算、可以复用。

响应自然

将人机交互研究的结果整合到真实的 UI 中

同步的更新变为可中断的异步更新

React15架构

React 15的架构可以分为两层:

  • Recociler(协调器):负责找出变化的组件
  • Renderer(渲染器):负责将变化的组件渲染到页面

Reconciler(协调器)

React中可以通过this.setStatethis.forceUpdateReactDOM.render等API触发更新。

每当有更新发生时,Reconciler会做如下工作:

  • 调用函数组件、或class组件的render方法,将返回的JSX转化为虚拟DOM
  • 将虚拟DOM和上次更新时的虚拟DOM对比
  • 通过对比找出本次更新中变化的虚拟DOM
  • 通知Renderer将变化的虚拟DOM渲染到页面上(找出需要重绘或重排的元素,告诉浏览器。浏览器根据相关的更新,重新计算 DOM Tree,重绘页面。)

Renderer(渲染器)

由于React支持跨平台,所以不同平台有不同的Renderer。我们前端最熟悉的是负责在浏览器环境渲染的Renderer —— ReactDOM

除此之外,还有:

  • ReactNative渲染器,渲染App原生组件
  • ReactTest渲染器,渲染出纯Js对象用于测试
  • ReactArt渲染器,渲染到Canvas, SVG 或 VML (IE8)

在每次更新发生时,Renderer接到Reconciler通知,将变化的组件渲染在当前宿主环境。

React15架构的缺点

  • Reconciler中,mount的组件会调用mountComponentupdate的组件会调用updateComponent。这两个方法都会递归更新子组件。
  • React15 的调度策略 – Stack reconcile。这个策略像函数调用栈一样,会深度优先遍历所有的 Virtual DOM 节点,进行Diff。它一定要等整棵 Virtual DOM 计算完成之后,才将任务出栈释放主线程。所以,在浏览器主线程被 React 更新状态任务占据的时候,用户与浏览器进行任何的交互都不能得到反馈,只有等到任务结束,才能突然得到浏览器的响应。【同步更新,不可中断
  • React 这样的调度策略对动画的支持也不好。如果 React 更新一次状态,占用浏览器主线程的时间超过 16.6 ms,就会被人眼发现前后两帧不连续,给用户呈现出动画卡顿的效果。【主流的浏览器刷新频率为60Hz,即每(1000ms / 60Hz)16.6ms浏览器刷新一次。我们知道,JS可以操作DOM,GUI渲染线程JS线程是互斥的。所以JS脚本执行浏览器布局、绘制不能同时执行。】
  • Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的。如果组件树的层级很深,递归会占用线程很多时间,造成卡顿。

React 16架构

React16架构可以分为三层:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

Scheduler(调度器)

既然我们以浏览器是否有剩余时间作为任务中断的标准,那么我们需要一种机制,当浏览器有剩余时间时通知我们。

React放弃使用 requestIdleCallback原因:【浏览器对 requestIdleCallback requestAnimationFrame 实现了类似功能】

  • 浏览器兼容性
  • 触发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的requestIdleCallback触发的频率会变得很低

基于以上原因,React实现了功能更完备的requestIdleCallbackpolyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。

Scheduler是独立于React的库

Reconciler(协调器)

React15React16,协调器(Reconciler)重构的一大目的是:将老的同步更新的架构变为异步可中断更新

异步可中断更新可以理解为:更新在执行过程中可能会被打断(①有其他更高优先级任务需要先更新②当前帧没有剩余时间),当可以继续执行时恢复之前执行的中间状态。

/** @noinline */
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

那么React16是如何解决中断更新时DOM渲染不完全的问题呢?

在React16中,ReconcilerRenderer不再是交替工作【React15架构的Reconciler和Renderer是交替工作的】。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记,类似这样:

export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;

整个SchedulerReconciler的工作都在内存中进行,不会更新到DOM上面。【所以即使反复中断,用户也不会看见更新不完全的DOM】只有当所有组件都完成Reconciler的工作,才会统一交给Renderer

Reconciler 内部采用了 Fiber 的结构。

Renderer(渲染器)

Renderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。

Fiber

Fiber 架构的心智模型:参考Fiber 架构的心智模型、代数效应入门

Fiber 的含义

Fiber包含三层含义:

  1. 作为架构来说,之前React15Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为stack ReconcilerReact16Reconciler基于Fiber节点实现,被称为Fiber Reconciler

    每个Fiber节点有个对应的React element,多个Fiber节点通过如下三个属性连接成树。

    // 指向父级Fiber节点
    this.return = null;
    // 指向子Fiber节点
    this.child = null;
    // 指向右边第一个兄弟Fiber节点
    this.sibling = null;
  2. 作为静态的数据结构来说,每个Fiber节点对应一个React element,保存了该组件的类型(函数组件/类组件/原生组件...)、对应的DOM节点等信息。

    // Fiber对应组件的类型 Function/Class/Host...
    this.tag = tag;
    // key属性
    this.key = key;
    // 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
    this.elementType = null;
    // 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
    this.type = null;
    // Fiber对应的真实DOM节点
    this.stateNode = null;
  3. 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(需要被删除/被插入页面中/被更新...)

    // 保存本次更新造成的状态改变相关信息
    this.pendingProps = pendingProps;
    this.memoizedProps = null;
    this.updateQueue = null;
    this.memoizedState = null;
    this.dependencies = null;
    
    this.mode = mode;
    
    // 保存本次更新会造成的DOM操作
    this.effectTag = NoEffect;
    this.nextEffect = null;
    
    this.firstEffect = null;
    this.lastEffect = null;

另外,如下两个字段保存调度优先级相关的信息,会在讲解Scheduler时介绍。

// 调度优先级相关
this.lanes = NoLanes;
this.childLanes = NoLanes;

Fiber 工作原理

Fiber节点可以保存对应的DOM节点。相应的,Fiber节点构成的Fiber树就对应DOM树。那么如何更新DOM呢?这需要用到被称为“双缓存”的技术。

双缓存是什么

  • 当我们用canvas绘制动画,每一帧绘制前都会调用ctx.clearRect清除上一帧的画面。
  • 如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。
  • 为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。
  • 这种在内存中构建并直接替换的技术叫做双缓存
  • React使用“双缓存”来完成Fiber树的构建与替换——对应着DOM树的创建与更新。

双缓存Fiber树

React中最多会同时存在两棵Fiber树当前屏幕上显示内容对应的Fiber树称为current Fiber树,正在内存中构建的Fiber树称为workInProgress Fiber树

current Fiber树中的Fiber节点被称为current fiberworkInProgress Fiber树中的Fiber节点被称为workInProgress fiber,他们通过alternate属性连接。

currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;

React应用的根节点通过current指针在不同Fiber树rootFiber间切换来实现Fiber树的切换。

workInProgress Fiber树构建完成交给Renderer渲染在页面上后,应用根节点的current指针指向workInProgress Fiber树,此时workInProgress Fiber树就变为current Fiber树

每次状态更新都会产生新的workInProgress Fiber树,通过currentworkInProgress的替换,完成DOM更新。

Fiber树的构建与替换过程

Fiber树的构建与替换过程,这个过程伴随着DOM的更新。

以具体例子讲解mount时update时的构建/替换流程

mount 时

function App() {
  const [num, add] = useState(0);
  return (
    <p onClick={() => add(num + 1)}>{num}</p>
  )
}

ReactDOM.render(<App/>, document.getElementById('root'));
  1. 首次执行ReactDOM.render会创建fiberRootNode(源码中叫fiberRoot)和rootFiber。其中**fiberRootNode是整个应用的根节点,rootFiber<App/>所在组件树的根节点**。

    之所以要区分fiberRootNoderootFiber,是因为在应用中我们可以多次调用ReactDOM.render渲染不同的组件树,他们会拥有不同的rootFiber。但是整个应用的根节点只有一个,那就是fiberRootNode

    fiberRootNodecurrent会指向当前页面上已渲染内容对应对Fiber树,被称为current Fiber树

    fiberRootNode.current = rootFiber;

    由于是首屏渲染,页面中还没有挂载任何DOM,所以fiberRootNode.current指向的rootFiber没有任何子Fiber节点(即current Fiber树为空)。

rootFiber

  1. 接下来进入render阶段,根据组件返回的JSX在内存中依次创建Fiber节点并连接在一起构建Fiber树,被称为workInProgress Fiber树。(下图中右侧为内存中构建的树,左侧为页面显示的树)

    在构建workInProgress Fiber树时会尝试复用current Fiber树中已有的Fiber节点内的属性,在首屏渲染时只有rootFiber存在对应的current fiber(即rootFiber.alternate)。

workInProgressFiber

  1. 图中右侧已构建完的workInProgress Fiber树commit阶段渲染到页面。

    此时DOM更新为右侧树对应的样子。fiberRootNodecurrent指针指向workInProgress Fiber树使其变为current Fiber 树

workInProgressFiberFinish

update 时

1.接下来我们点击p节点触发状态改变,这会开启一次新的render阶段并构建一棵新的workInProgress Fiber 树

wipTreeUpdate

mount时一样,workInProgress fiber的创建可以复用current Fiber树对应的节点数据。

这个决定是否复用的过程就是Diff算法,后面章节会详细讲解

2.workInProgress Fiber 树render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树

currentTreeUpdate

Fiber Reconciler 与 Stack Reconciler 的不同

Fiber 是一种轻量的执行线程,同线程一样共享定址空间,线程靠系统调度,并且是抢占式多任务处理,Fiber 则是自调用,协作式多任务处理。

首先,使用协作式多任务处理任务。将原来的整个 Virtual DOM 的更新任务拆分成一个个小的任务。每次做完一个小任务之后,放弃一下自己的执行将主线程空闲出来,看看有没有其他的任务。如果有的话,就暂停本次任务,执行其他的任务,如果没有的话,就继续下一个任务。

整个页面更新并重渲染过程分为两个阶段。

  1. Reconcile 阶段。此阶段中,依序遍历组件,通过 diff 算法,判断组件是否需要更新,给需要更新的组件加上 tag。遍历完之后,将所有带有 tag 的组件加到一个数组中。这个阶段的任务可以被打断。
  2. Commit 阶段。根据在 Reconcile 阶段生成的数组,遍历更新 DOM,这个阶段需要一次性执行完。如果是在其他的渲染环境 – Native,硬件,就会更新对应的元素。

所以之前浏览器主线程执行更新任务的执行流程就变成了这样。

img

其次,对任务进行优先级划分。不是每来一个新任务,就要放弃现执行任务,转而执行新任务。与我们做事情一样,将任务划分优先级,只有当比现任务优先级高的任务来了,才需要放弃现任务的执行。比如说,屏幕外元素的渲染和更新任务的优先级应该小于响应用户输入任务。若现在进行屏幕外组件状态更新,用户又在输入,浏览器就应该先执行响应用户输入任务。浏览器主线程任务执行流程如下图所示。

img

使用了 ReactFiber 去渲染整个页面,ReactFiber 会将整个更新任务分成若干个小的更新任务,然后设置一些任务默认的优先级。每执行完一个小任务之后,会释放主线程。

需要考虑的问题:

  • 比如说,task 按照优先级之后,可能低优先级的任务永远不会执行,称之为 starvation;
  • 比如说,task 有可能被打断,需要重新执行,那么某些依赖生命周期实现的业务逻辑可能会受到影响。
  • ……

React Fiber 也是带来了很多的好处的。

  • 比如说,增强了某些领域的支持,比如动画、布局和手势;
  • 比如说,在复杂页面,对用户的反馈会更及时,应用的用户体验会变好,简单页面看不到明显的差异;
  • 比如说,api 基本上没有变化,对现有项目很友好。

资料引用

@SampsonKY SampsonKY self-assigned this Oct 14, 2020
@Aaronphy
Copy link

不错 ,讲的很好

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants