React Fiber调度调和流程原理

361 阅读11分钟

v18版本之前 Legacy 模式下的 React 应用中,更新本质上有两种场景:

  • 第一种就是初始化的时候第一次页面的呈现。
  • 第二种就是初始化完毕,state 的更新,比如点击按钮,触发 setState 或者 useState

ReactDOM.render

假设现在开始初始化我们的应用,一个传统的 React 应用从 ReactDOM.render 方法开始:

import ReactDOM from 'react-dom'
/* 通过 ReactDOM.render  */
ReactDOM.render(
    <App />,
    document.getElementById('app')
)

ReactDOM.render主要做的事情是形成一个 Fiber Tree 挂载到 app 上,包括:

  • 创建整个应用的 FiberRoot 。
  • 然后调用 updateContainer 开始非批量初始化更新,意味着直接进入调和阶段同步更新,不会放入到调度任务中。

由此引出几个关键概念和疑问:Fiber,调度( Scheduler )和调和( Reconciler )。

Fiber

什么是fiber?

fiber 是在 React 中的最小粒度的执行单元,也可以理解成就是 React 虚拟Dom 的节点。

为什么要用fiber?

但有个问题可能大家都清楚,那就是 GUI 渲染线程和 JS 引擎线程是相互排斥的,比如开发者用 js 写了一个遍历大量数据的循环,在执行 js 时候,会阻塞浏览器的渲染绘制,给用户直观的感受就是卡顿, 在 Reactv15 以及之前的版本,React 对于虚拟 DOM 是采用递归方式遍历更新的,比如一次更新,就会从应用根部递归更新,递归一旦开始,中途无法中断,代码大了会给前端交互上的体验造成卡顿的问题越来越明显,所以推出了这套基于Fiber节点的架构,用对应的调度( Scheduler )和调和( Reconciler )机制来解决这个问题。

它如何解决卡顿?

总体来说就是,每一个 fiber 都可以作为一个执行单元来处理,每一个 fiber 可以根据自身的过期时间expirationTime( v17 版本叫做优先级 lane )来判断是否还有空间时间执行更新,如果没有时间更新,就要把主动权交给浏览器去渲染,做一些动画,重排( reflow ),重绘 repaints 之类的事情,然后等浏览器空余时间,在通过 scheduler (调度器),再次恢复执行单元上来,这样就能本质上中断了渲染,提高了用户体验。

Element,Fiber,Dom三个概念什么关系?

  • element 是 React 视图层在代码层级上的表象,也就是我们写的 jsx 元素结构编译后形成的对象,保存了 props , children ,state等信息。
  • DOM 是元素在浏览器上给用户直观的表象。
  • 而Fiber 如图所示,是 element 和真实 DOM 之间的桥梁。

image.png

Fiber之间如何建立关联?

每一个 element 都会对应一个 fiber ,每一个 fiber 是通过 return , child ,sibling 三个属性建立起联系的,并用独特的tag标记对应记录Element的类型,同时保存了alternate,effect,memoizedProps,childLanes,expirationTime等调度和调和需要的信息。

image.png

到现在,我们至少需要有个确定的概念就是,在 React 内部,不止我们的 JSX 形成的 Element 树,还有一个与之对应的Fiber树,同时一一对应的真实 Dom 树,我们写下的 JSX 代码,在 React 内部得经过这三层模型处理成我们最后可以交互的页面。接下来就要从初始化和一次更新入手,看一下 fiber 是如何具体工作的。

Fiber 更新机制

初始化

第一步:从render方法创建fiberRoot和rootFiber

  • rootFiber:ReactDOM.render 第一个参数创建的元素。
  • fiberRoot:第一次挂载的过程中,会创建一个 fiberRoot 节点, 其current属性指向某个rootFiber,作为整个 React 应用的根基。

image.png

一个 React 应用可以有多个 rootFiber ,但是只能有一个作为 fiberRoot(应用根节点)。

第二步:workInProgress和current

一旦两个root对象关联完成,进入调和阶段,之前我们了解到Fiber节点之间通过几个属性链接成树,现在更进一步,需要了解到 React 的 Fiber 使用的是双缓冲树这个数据结构去构建的,就意味着存在着在JSX Element和真实Dom之间其实不止一个而是有两颗 Fiber 树,分别是:

  • current:正在视图层渲染的树叫做 current 树。
  • workInProgress:正在内存中构建的 Fiber 树被称为 workInProgress 树。在一次更新中,所有的更新都是发生在 workInProgress 树上。在一次更新之后,一旦 workInProgress 树上的状态是最新的,那么它将变成 current 树用于渲染视图。

开始第一次调和,会进入 beginwork 流程,首先会复用当前 current 树( 第一次只有rootFiber一个节点 )的 alternate 作为 workInProgress 树的根(如果是第一次没有就创建一个Fiber作为workInProgress,并双向赋值alternate属性)

image.png

第三步:深度调和子节点,渲染视图

接下来会按照上述第二步,在新创建的 alternates 上,依次完成整个 fiber 树的遍历创建,类似于这样:

image.png

最后会把此时的 workInProgress 作为最新的current渲染树,把fiberRoot 的 current 指针指向 workInProgress 使其变为 current Fiber 树。到此完成 React 应用第一次初始化流程。

image.png

那之后如果,点击一次按钮,我们的 React 发生更新,会如何更新Fiber呢? 交替复写workInProgress!第一次初始化把新构建的workInProgress(上面图的右边)最后作为了当前的current,那左边这个RootFiber不是空出来了吗?!是的,第一次的更新将在左边发生!而后续的第三次,第四次,等等更新将交替在左右树上更新,然后不断切换fiberRoot的current属性指向就可以了,每个Fiber节点之间都使用alternate连接,以此来承载虚拟Dom的Diff对比:

image.png

调和流程

接下来我们来具体看看这个树是如何遍历完成创建和更新的,涉及到 render 和 commit 两个fiber Reconciler 调和的核心阶段

Render阶段

function workLoop (){
    while (workInProgress !== null ) {
      workInProgress = performUnitOfWork(workInProgress);
    }
}

在调和过程中,每一个发生更新的 fiber 都会作为一次 workInProgress 执行 performUnitOfWork方法去遍历。如果渲染没有被中断,那么 workLoop 会遍历一遍 fiber 树,而 performUnitOfWork 方法里又包括两个阶段 beginWork 和 completeWork 。

function performUnitOfWork(){
    next = beginWork(current, unitOfWork, renderExpirationTime);
    if (next === null) {
       next = completeUnitOfWork(unitOfWork);
    }
}

beginWork负责向下调和,大概内容是:

  • 首先做switch(workInProgress.tag)判断,前面我们说了Fiber树是保存的有对应的Element类型标签的()12345...)根据不同的tag判断执行对应逻辑,比如执行一些此时的生命周期,执行 render 方法,计算 children 等。
  • 然后向下遍历 children ,做 oldFiber diff ,也就是前面说到的双缓冲Fiber树,新老节点用alternate连接),判断更新来源,判断props变化,以及 useMemo,memo 等缓存方法去确定是否更新。
  • 最后根据结果打好不同的副作用标签 effectTag 用于后面修改真实dom使用。

completeUnitOfWork负责向上调和:

  • 如果有兄弟节点,会处理 sibling 兄弟,没有就处理 return 的父级,一直返回到 fiebrRoot ,进一步将打了 effectTag 的 Fiber 节点保存在一条被称为 effectList 的单向链表中。在 commit 阶段,将不再需要遍历每一个 fiber ,只需要执行更新 effectList 就可以了。
  • 对于组件处理 context ;对于元素标签初始化,会创建真实 DOM ,将子孙 DOM 节点插入刚生成的 DOM 节点中;会触发 diffProperties 处理 props ,比如事件收集,style,className 处理

这么一上一下两个方法的指针交替游走,完成Fiber树的遍历,构成了整个调和的render阶段。

Commit阶段

这个阶段拿到前面 render 阶段遍历好的 effectList 链表直接做遍历更新Dom,做增删改查,一方面是对一些重要生命周期和副作用钩子的处理,比如 componentDidMount ,函数组件的 useEffect ,useLayoutEffect 等;还有一些细节比如 ref 的处理。

可以分为三个部分:

  • Before mutation 阶段在执行 DOM 操作前:这时还没修改真实的 DOM ,执行对应生命周期,异步调用 useEffect
  • mutation 阶段(执行 DOM 操作):置空 ref,根据前面打的 effectTag 做真实的 Dom 增删改查。
  • layout 阶段(执行 DOM 操作后):执行对应生命周期,setState 的callback,对于函数组件会执行 useLayoutEffect 钩子。如果有 ref ,会重新赋值 ref 。

至此,从ReactDom.render页面初始化,到一次更新调和完成的 React 内部更新的主流程已经明朗,但似乎目前为止依然没有解决卡顿问题,并且 React 似乎无法打破从 root 开始遍历‘找不同’的命运,那怎么办,既然更新过程阻塞了浏览器的绘制,那么把 React 的更新,交给浏览器自己控制不就可以了吗,如果浏览器有绘制任务那么执行绘制任务,在空闲时间执行更新任务,就能解决卡顿问题了,配合这套Fiber调和更新架构的调度( Scheduler )机制就是具体的实现方式。

调度流程

控制进入调度

如果有一个组件 A ,如果想要它更新,那么场景有如下情况:

  • 组件本身改变 state 。函数 useState | useReducer ,类组件 setState | forceUpdate
  • props 改变,由组件更新带来的子组件的更新。
  • context更新,并且该组件消费了当前 context 。

无论是哪种方式创造的组件更新,本质就是:

  • 创建一个任务优先级 lane(expirationTime)。
  • 然后进行 scheduleUpdateOnFiber
function scheduleUpdateOnFiber(fiber,lane){
    /* 递归向上标记更新优先级 */
    const root = markUpdateLaneFromFiberToRoot(fiber, lane);
    if(root === null) return null
    /* 如果当前 root 确定更新,那么会执行 ensureRootIsScheduled */
    ensureRootIsScheduled(root, eventTime);
}

scheduleUpdateOnFiber 主要做了两件事:

  • 第一个就是通过markUpdateLaneFromFiberToRoot标记优先级,正常情况下,大多数任务都是 SyncLane。即便在异步任务里面触发的更新,比如在 Promise 或者是 setTimeout 里面的更新,也是 SyncLane
  • 在 unbatch 情况下,会直接进入到 performSyncWorkOnRoot ,直接进入到调和阶段,会从 rootFiber 开始向下遍历。
  • 那么任务是 useState 和 setState,那么会进入到 else 流程,进入到 ensureRootIsScheduled 调度流程。
  • 当前的执行任务类型为 NoContext ,说明当前任务是非可控的,那么会调用 flushSyncCallbackQueue 方法立即执行更新。

markUpdateLaneFromFiberToRoot 如何标记的优先级?

  • 首先会更新当前 fiber 上的更新优先级。在 fiber 章节我们讲过,fiber 架构采用 ‘连体婴’形式的双缓冲树,所有还要更新当前 fiber 的缓冲树 alternate 上的优先级。
  • 然后会递归向上把父级连上的 childLanes 都更新,更新成当前的任务优先级。
  • 这样当React从 Root Fiber 向下调和的时候,发现 childLanes 等于当前更新优先级,那么说明它的 child 链上有新的更新任务,则会继续向下调和,反之退出调和流程,从而准确找到需要更新的组件。

ensureRootIsScheduled 最后如何更新?

所有非初始化类型的更新任务,那么最终会走到这个方法,主要做的事情有:

  • 首先会计算最新的调度更新优先级 newCallbackPriority,接下来获取当前 root 上的 callbackPriority 判断两者是否相等。如果两者相等,那么将直接退出不会进入到调度中。
  • 如果不想等那么会真正的进入调度任务 scheduleSyncCallback 中。注意的是放入调度中的函数就是调和流程的入口函数 performSyncWorkOnRoot
  • 函数最后会将 newCallbackPriority 赋值给 callbackPriority。

什么情况下会存在 existingCallbackPriority === newCallbackPriority,退出调度的情况?

我们注意到在一次更新中最后 callbackPriority 会被赋值成 newCallbackPriority 。那么如果在正常模式下(非异步)一次更新中触发了多次 setState 或者 useState ,那么第一个 setState 进入到 ensureRootIsScheduled 就会有 root.callbackPriority = newCallbackPriority,那么接下来如果还有 setState | useState,那么就会退出,将不进入调度任务中,原来这才是批量更新的原理,多次触发更新只有第一次会进入到调度中。

进入调度

当进入到 scheduleSyncCallback 中会发生什么呢?

  • 如果执行队列为空,那么把当前任务放入队列中。然后执行调度任务。
  • 如果队列不为空,此时已经在调度中,那么不需要执行调度任务,只需要把当前更新放入队列中就可以,调度中心会一个个按照顺序执行更新任务。

调度阶段正在编写...