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 之间的桥梁。
Fiber之间如何建立关联?
每一个 element 都会对应一个 fiber ,每一个 fiber 是通过 return , child ,sibling 三个属性建立起联系的,并用独特的tag标记对应记录Element的类型,同时保存了alternate,effect,memoizedProps,childLanes,expirationTime等调度和调和需要的信息。
到现在,我们至少需要有个确定的概念就是,在 React 内部,不止我们的 JSX 形成的 Element 树,还有一个与之对应的Fiber树,同时一一对应的真实 Dom 树,我们写下的 JSX 代码,在 React 内部得经过这三层模型处理成我们最后可以交互的页面。接下来就要从初始化和一次更新入手,看一下 fiber 是如何具体工作的。
Fiber 更新机制
初始化
第一步:从render方法创建fiberRoot和rootFiber
rootFiber:ReactDOM.render 第一个参数创建的元素。fiberRoot:第一次挂载的过程中,会创建一个 fiberRoot 节点, 其current属性指向某个rootFiber,作为整个 React 应用的根基。
一个 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属性)
第三步:深度调和子节点,渲染视图
接下来会按照上述第二步,在新创建的 alternates 上,依次完成整个 fiber 树的遍历创建,类似于这样:
最后会把此时的 workInProgress 作为最新的current渲染树,把fiberRoot 的 current 指针指向 workInProgress 使其变为 current Fiber 树。到此完成 React 应用第一次初始化流程。
那之后如果,点击一次按钮,我们的 React 发生更新,会如何更新Fiber呢? 交替复写workInProgress!第一次初始化把新构建的workInProgress(上面图的右边)最后作为了当前的current,那左边这个RootFiber不是空出来了吗?!是的,第一次的更新将在左边发生!而后续的第三次,第四次,等等更新将交替在左右树上更新,然后不断切换fiberRoot的current属性指向就可以了,每个Fiber节点之间都使用alternate连接,以此来承载虚拟Dom的Diff对比:
调和流程
接下来我们来具体看看这个树是如何遍历完成创建和更新的,涉及到 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 ,执行对应生命周期,异步调用 useEffectmutation阶段(执行 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 中会发生什么呢?
- 如果执行队列为空,那么把当前任务放入队列中。然后执行调度任务。
- 如果队列不为空,此时已经在调度中,那么不需要执行调度任务,只需要把当前更新放入队列中就可以,调度中心会一个个按照顺序执行更新任务。
调度阶段正在编写...