React 渲染流程
对于首次渲染,React 的主要工作就是将
React.render 接收到的 VNode 转化 Fiber 树,并根据 Fiber 树的层级关系,构建生成出 DOM 树并渲染至屏幕中。
而对于更新渲染时,Fiber 树已经存在于内存中了,所以 React 更关心的是计算出 Fiber 树中的各个节点的差异,并将变化更新到屏幕中。
两个阶段
为了实现concurrent Mode模式,React将渲染更新的过程分为了两个阶段:
- render 阶段,利用双缓冲技术,在内存中构造另一颗Fiber树,在其上进行协调计算,找到需要更新的节点并记录,这个过程会被重复中断恢复执行。
- commit 阶段,根据 render 阶段的计算结果,执行更新操作,这个过程是同步执行的。
注:Concurrent Mode 只是 Async Mode 的 重新定义,来凸显出 React 在不同优先级上的执行能力,与其它的异步渲染方式进行区分。
Concurrent Mode 到底是什么呢?
Concurrent 并不是一个新的概念,在 Fiber 诞生之初就被不停地提及, 下面是Stack Reconciler 与 Fiber Reconciler 运行区别,Fiber Reconciler 就是 Concurrent 的雏形。
这个 Demo 是几种种模式下,input输入框连续输入的相应状态
Sync 模式下页面是完全卡顿的,input 连续输入得不到响应,Debounced 模式下尽管连续输入流畅,但由于变更被统一延迟,下方图表没有随输入改变而重渲染,只有 Concurrent 下是正常的体验,输入流畅,图表也随之而变更。(serTimeout/throttle 两种模式暂不做说明)
Concurrent模式,它能使 React 在长时间渲染的场景下依旧保持良好的交互性,能优先执行高优先级变更,不会使页面处于卡顿或无响应状态,从而提升应用的用户体验。
从浏览器原理说起。为什么Concurrent的用户体验高于其他几种模式
Concurrent 目的在于提升卡顿页面的体验,
页面卡顿的原因是什么呢?大家都知道JS是单线程的,浏览器是多线程的,除了JS线程以外,还包括UI渲染线程、事件线程、定时器触发线程、HTTP请求线程等等。JS线程是可以操作DOM的,如果在操作DOM的同时UI线程也在进行渲染的话,就会发生不可预期的展示结果,因此JS线程与UI渲染线程是互斥的,每当JS线程执行时,UI渲染线程会挂起,UI更新会被保存在队列中,等待JS线程空闲后立即被执行。对于事件线程而言,当一个事件被触发时该线程会把事件添加到队列末尾,等待JS线程空闲后处理。因此,长时间的 JS持续执行,就会造成UI渲染线程长时间地挂起,触发的事件也得不到响应,用户层面就会感知到页面卡顿甚至卡死了,Sync模式下的问题就由此引起的。
那么JS执行时间多久才是最好的?这里就需要提到帧率了,大多数设备的帧率为60次/秒,也就是每帧消耗 16.67 ms 能让用户感觉到相当流畅。浏览器的一帧中包含如下图过程:
在一帧中,我们需要将JS执行时间控制在合理的范围内,不影响后续Layout与Paint 的过程。而经常被大家所提及的requestIdleCallback 就能够充分利用帧与帧之间的空闲时间来执行JS代码,可以根据callback传入的dealine判断当前是否还有空闲时间,用于执行。由于浏览器可能始终处于繁忙的状态,导致callback 一直无法执行,它还能够设置超时时间,一旦超过时间能使任务被强制执行。
// 浏览器执行线程空闲时间调用 myWork,超过 2000ms 后立即必须执行
requestIdleCallback(myWork, { timeout: 2000 });
function myWork(deadline) {
// 如果有剩余时间,或者任务已经超时,并且存在任务就需要执行
while (
(deadline.timeRemaining() > 0 || deadline.didTimeout) &&
tasks.length > 0
) {
doWorkIfNeeded();
}
// 当前存在任务,再次调用 requestIdleCallback,会在空闲时间执行 myWork
if (tasks.length > 0) {
requestIdleCallback(myWork, { timeout: 2000 });
}
}
requestIdleCallback是在Layout与Paint之后执行的,这也就意味着 requestIdleCallback里适合做JS计算,如果再进行DOM的变更,会重新触发 Layout与Paint,帧的时间也会因此不可控,requestIdleCallback 的兼容性也比较差。在React内部采用requestAnimationFrame作为 ployfill,通过 帧率动态调整,计算 timeRemaining,模拟 requestIdleCallback,从而实现时间分片(Time Slicing),一个时间片就是一个渲染帧内JS能获得的最大执行时间。requestAnimationFrame 触发在Layout 与 Paint之前,方便做 DOM 变更
但是卡顿也有可能是大量Layout 或是 Paint 造成的。
为了避免长时间的JS运行对于UI 渲染以及界面交互产生影响,我们需要对JS 进行控制。你不能一直执行下去,一个时间片后,要“踩刹车”,停下来,让页面完成交互和渲染,而交互所产生的变更要及时反馈给用户,比之前“踩刹车”任务更重要,需要“插个队”,等该任务完成后,再继续“踩刹车”任务,
Fiber
从程序架构的角度来看,为了实现concurrent 模式,需要程序具备的可中断、可恢复的特性, 而之前VNode 的树型结构很难完成这些操作,所以 Fiber 就应运而生了。每个 Virtual DOM 都可以表示为一个 fiber节点。
Fiber是一个链表结构,通过child、sibling、return三个属性记录了树型结构中的子节点、兄弟节点、父节点的关系信息,从而可以实现从任一节点出发,都可以访问其他节点的特性。
除了作为链表的结构之外,程序运行时还需要记录组件的各种状态、实例、真实DOM元素映射等等信息,这些都会被记录在 Fiber 这个对象身上。
return、child、sibling
这三个属性主要用途是将每个 Fiber 节点连接起来,用链表的结构来描述树型结构的关系。
- child:指向第一个子节点
- sibling(兄弟节点)
- return(父节点)等属性
firber节点图示:
function FiberNode() {
this.tag = tag
this.key = key
this.elementType = null
this.type = null
this.stateNode = null
this.return = null
this.child = null
this.sibling = null
this.index = 0
this.ref = null
this.pendingProps = pendingProps
this.memoizedProps = null
this.updateQueue = null
this.memoizedState = null
this.dependencies = null
this.mode = mode
this.effectTag = NoEffect
this.nextEffect = null
this.firstEffect = null
this.lastEffect = null
this.expirationTime = NoWork
this.childExpirationTime = NoWork
this.alternate = null
}
名词解释一:effectTag(flags)副作用标记
标识了此 Fiber节点需要进行哪些操作,默认为 NoEffect。标记了 NoEffect、PerformedWork 的节点在更新过程中会被跳过。
名词解释二:nextEffect、firstEffect、lastEffect链表结构
保存了需要更新的后代节点,每个 Fiber 节点处理完自身后都会根据相应逻辑与父节点的 lastEffect 进行连接。这样在 commit 阶段,只需要从根的 firstEffect 向下遍历,就可以将所有需要更新的节点进行相应处理了。
名词解释三:updateQueue
保存了同一事件循环中对组件的多次更新操作(多次调用 setState )的队列
名词解释四:tag
tag 描述了 Fiber 节点的类型
名词解释五:stateNode
Fiber 节点的 stateNode 属性存储的当前节点的最终产物
ClassComponent类型的节点则该属性指向的是当前Class组件的实例HostComponent类型的节点则该属性指向的是当前节点的DOM实例HostRoot类型的节点则该属性指向的是fiberRoot对象
名词解释六:FiberRootNode
fiberRoot 对象是整个 Fiber架构 的入口对象,其上记录了应用程序运行过程中需要保存的关键信息。
function FiberRootNode(containerInfo, tag, hydrate) {
this.tag = tag
// current树
this.current = null
// 包含容器
this.containerInfo = containerInfo
this.pendingChildren = null
this.pingCache = null
this.finishedExpirationTime = NoWork
// 存储工作循环(workLoop)结束后的副作用列表,用于commit阶段
this.finishedWork = null
this.timeoutHandle = noTimeout
this.context = null
this.pendingContext = null
this.hydrate = hydrate
this.firstBatch = null
}
containerInfo 保存了 React.render 函数第二个参数,也就是程序的真实 DOM 容器。
current 属性既是应用程序中 Fiber树 的入口。
current 的值是一个 HostRoot 类型的 Fiber 节点,这个 HostRoot 的子节点就是程序的根组件(App)对应的 Fiber 节点。
在首次渲染调用 React.render 时,应用程序中其实只有一个 HostRoot 的 Fiber 节点,而在 render 过程中,才会将我们传入的 App 组件构建成 HostRoot 的子 Fiber 节点。
名词解释七:双缓冲
双缓冲是指将需要变化的部分,先在内存中计算改变,计算完成后一次性展示给用户,这样用户就不会感知到明显的计算变化。离屏 Canvas 就是双缓冲的思想。
对于 Concurrent 模式来说,更新计算的过程会被频繁中断,如果不使用缓冲技术,那用户就会感知到明显的中断变化。每个 Fiber 节点的 alternate 属性会指向另一个 Fiber 节点,这个 Fiber 节点就是「草稿」节点,当需要进行计算时,就会在这个节点上进行。计算完成后将两个节点进行互换,展示给用户。作为已经计算完成并展示到视图中的 Fiber 树,在源码中称为 current 树。 而 current 树的 alternate 指向的另一棵树,就是用来计算变化的,称为 WorkInProgress 树( WIP )。
名词解释八:组件
函数或者是类,最终产出 VNode 和定义生命周期钩子。
名词解释九:组件实例
类组件实例化后的对象,其上记录了生命周期函数、组件自身状态、响应事件等。对于函数组件来说,没有实例对象,所以在 hooks 出现之前函数组件不能拥有自己的状态,而在 hooks 之后,函数组件通过调用 hooks 的产生状态被记录在组件对应的 Fiber 对象中。
名词解释十:update(更新对象)
包含过期时间、更新内容的对象。
名词解释十一:updateList(更新队列)
update 的集合,链表结构。React 的更新操作都是异步执行的,在同一个宏任务中执行的更新操作都会被记录在此处,统一在下一个队列中执行。
更新队列
不管是首次渲染还是更新渲染,都一定会经过以下步骤:
- 创建更新对象
- 加入更新队列
- 遍历合并更新队列获取最终的状态值。
更新队列的作用
主要是对同步的多次调用 setState 进行缓冲,避免冗余的渲染调用。
多次触发更新(setState)
触发更新操作时,React 会从 this(类组件)或 hooks 返回的 setter 函数中找到对应的 Fiber 节点,然后根据传入 setState 的参数创建更新对象,并将更新对象保存在 Fiber 节点的 updateQueue 中。 这样我们在同一个事件循环中对组件的多次修改操作就可以记录下来,在下一个事件循环中统一进行处理。处理时就会遍历 updateQueue 中的修改,依次合并获取最终的 state 进行渲染。
更新对象定义
function createUpdate(expirationTime, suspenseConfig) {
var update = {
// 过期时间与任务优先级相关联
expirationTime: expirationTime,
suspenseConfig: suspenseConfig,
// tag用于标识更新的类型如UpdateState,ReplaceState,ForceUpdate等
tag: UpdateState,
// 更新内容
payload: null,
// 更新完成后的回调
callback: null,
// 下一个更新(任务)
next: null,
// 下一个副作用
nextEffect: null
};
{
// 优先级会根据任务体系中当前任务队列的执行情况而定
update.priority = getCurrentPriorityLevel()
}
return update
}
为了防止某个 update 因为优先级的问题一直被打断,React 给每个 update 都设置了过期时间(expirationTime),当时间到了就会强制执行改 update。
expirationTime 会根据任务的优先级计算得来
更新队列定义
function createUpdateQueue(baseState) {
var queue = {
// 当前的state
baseState: baseState,
// 队列中第一个更新
firstUpdate: null,
// 队列中的最后一个更新 lastUpdate: null,
// 队列中第一个捕获类型的update firstCapturedUpdate: null,
// 队列中第一个捕获类型的update lastCapturedUpdate: null,
// 第一个副作用
firstEffect: null,
// 最后一个副作用
lastEffect: null,
firstCapturedEffect: null,
lastCapturedEffect: null,
}
return queue
}
初始渲染流程
- 根组件的
JSX定义会被babel转换为React.createElement的调用,其返回值为VNode树。 React.render调用,实例化FiberRootNode,并创建根Fiber节点HostRoot赋值给FiberRoot的current属性- 创建更新对象,其更新内容为
React.render接受到的第一个参数VNode树,将更新对象添加到HostRoot节点的updateQueue中 - 处理更新队列,从
HostRoot节点开始遍历,在其alternate属性中构建WIP树,在构建Fiber树的过程中会根据VNode的类型进行组件实例化、生命周期调用等工作,对需要操作视图的动作将其保存到Fiber节点的effectTag上面,将需要更新在DOM上的属性保存至updateQueue中,并将其与父节点的lastEffect连接。 - 当整颗树遍历完成后,进入
commit阶段,此阶段就是将effectList收集的DOM操作应用到屏幕上。 commit完成将current替换为WIP树。
构建WIP树
React 会先以 current 这个 Fiber 节点为基础,创建一个新的 Fiber 节点并赋值给 current.alternate 属性,然后在这个 alternate 节点上进行协调计算,这就是之前所说的 WIP 树。
协调时会在全局记录一个 workInProgress 指针,用来保存当前正在处理的节点,这样中断之后就可以在下一个事件循环中接着进行协调。
此时整个更新队列中只有 HostRoot 这一个 Fiber 节点,对当前节点处理完成之后,会调用 reconcileChildren 方法来获取子节点,并对子节点做同样的处理流程。
Fiber节点处理
- 创建当前节点,并返回子节点
- 如果子节点为空,则执行叶子节点逻辑
- 否则,将子节点赋值给
workInProgress指针,作为下一个处理的节点。
这里主要说一下三种主要节点:HostRoot、ClassComponent、HostComponent
-
HostRoot:对于
HostRoot主要是处理其身上的更新队列,获取根组件的元素。 -
ClassComponent:解析完
HostRoot后会返回其child节点,一般来说就是ClassComponent了。这种类型的Fiber节点是需要进行组件实例化的,实例会被保存在Fiber的stateNode属性上。 实例化之后会调用render拿到其VNode再次进行构建过程。对于数组类型的VNode,会使用sibling属性将其相连。 -
HostComponent:
HostComponent就是原生的DOM类型了,会创建DOM对象并保存到stateNode属性上。
叶子节点逻辑
简单思考一下,叶子节点必然是一个 DOM 类型的节点,也就是 HostComponent,所以对叶子节点的处理可以理解为将 Fiber 节点映射为 DOM 节点的过程。
当碰到叶子节点时,会创建相应的 DOM 元素,然后将其记录在 Fiber 的 stateNode 属性中,然后调用 appendAllChildren 将子节点创建好的的 DOM 添加到 DOM 结构中。
叶子节点处理完毕后
- 如果其兄弟节点存在,就将
workInProgress指针指向其兄弟节点。 - 否则就将
workInProgress指向其父节点。
收集副作用
收集副作用的过程中主要有两种情况
- 第一种情况是将当前节点的副作用链表添加到父节点中
returnFiber.lastEffect.nextEffect = workInProgress.firstEffect
- 第二种情况就是如果当前节点也有副作用标识,则将当前节点连接到父节点的副作用链表中
returnFiber.lastEffect.nextEffect = workInProgress
处理副作用
从根节点的 firstEffect 开始向下遍历
before mutation:遍历effectList,执行生命周期函数getSnapshotBeforeUpdate,使用scheduleCallback异步调度flushPassiveEffects方法(useEffect逻辑)mutation:第二次遍历,根据Fiber节点的effectTag对DOM进行插入、删除、更新等操作;将effectList赋值给rootWithPendingPassiveEffectslayout:从头再次遍历,执行生命周期函数,如componentDidMount、DidUpdate等,同时会将current替换为WIP树,置空WIP树;scheduleCallback触发flushPassiveEffects,flushPassiveEffects内部遍历rootWithPendingPassiveEffects
渲染完成
至此整个 DOM 树就被创建并插入到了 DOM 容器中,整个应用程序也展示到了屏幕上,初次渲染流程结束。
更新渲染流程
- 组件调用
setState触发更新,React通过this找到组件对应的Fiber对象,使用setState的参数创建更新对象,并将其添加进Fiber的更新队列中,然后开启调度流程。 - 从根
Fiber节点开始构建WIP树,此时会重点处理新旧节点的差异点,并尽可能复用旧的Fiber节点。 - 处理
Fiber节点,检查Fiber节点的更新队列是否有值,context是否有变化,如果没有则跳过。
- 处理更新队列,拿到最新的
state,调用shouldComponentUpdate判断是否需要更新。
- 调用
render方法获取VNode,进行diff算法,标记effectTag,收集到effectList中。
- 对于新元素,标记插入
Placement - 旧
DOM元素,判断属性是否发生变化,标记Update - 对于删除的元素,标记删除
Deletion
- 遍历处理
effectList,调用生命周期并更新DOM。
Fiber Diff
节点比较
当 key 和 type 都相同时,会复用之前的 Fiber 节点,否则则会新建并将旧节点标记删除。
任务与调度
时间切片
在 Concurrent 模式下,任务以 Fiber 为单位进行执行,当 Fiber 处理完成,或者 shouldYield 返回值为 true 时,就会暂停执行,让出线程。
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress)
}
在 shouldYield 中会判断当前时间与当前切片的过期时间,如果过期了,就会返回 true,而当前时间的过期时间则是根据不同的优先级进行计算得来。
与浏览器通信 - MessageChannel
对于浏览器而言,如果我们想要让出js线程,那就是只能把当前的宏任务执行完成。等到下一个宏任务中再接着执行。当浏览器执行完一个宏任务后就会切换只渲染进程进行视图的渲染工作。MessageChannel可以创建一个宏任务,其优先级比setTimeout(0)高。