前言
本文内容涉及到很多渲染链路中的原理以及源码方法,所以在看本文之前,需要对于React的render渲染流程有大致的了解。不清楚的同学可以先看我的第一篇源码解析文章。
写给自己看的React源码解析(一):你的React代码是怎么渲染成DOM的?
本文主要解析fiber架构更新链路的双缓冲模式以及Concurrent模式下时间切片,优先级的实现原理。
双缓冲
双缓冲模式的主要用处,是能够帮我们较大限度地实现Fiber节点的复用,减少性能方面的开销。
在之前的文章中,我们知道在首次渲染的时候会创建出两颗树,current树与workInProgress树。current树与workInProgress树,其实就是两套缓冲数据:当current树被渲染到页面上时,所有的数据更新都会由workInProgress树来承接。workInProgress树将会在内存里悄悄地完成所有改变,直到下次进行渲染的commit阶段执行完毕之后,fiberRoot对象的current会指向workInProgress树,workInProgress树就会变成渲染到页面上的current树。
我们用一个实际例子来帮助理解:
import { useState } from 'react';
function App() {
const [state, setState] = useState(0)
return (
<div className="App">
<div onClick={() => { setState(state + 1) }}>
<p>{state}</p>
</div>
</div>
);
}
初始化
这个例子的功能很简单,就是点击一次,数字加1。上面的demo在render阶段结束后,commit阶段结束前的两颗fiber树如下图所示
commit阶段完成,workInProgress树被渲染到页面上,这时候fiberRoot对象的current会指向workInProgress树,这个当前被渲染的fiber树。
第一次更新
点击一次数字,我们进入第一次的更新流程。重点看beginWork调用链路中的createWorkInProgress方法。
上图中,workInProgress树下面的子节点的current.alternate对应的就是current树的子节点,但是current树目前没有子节点,所以为null,进入等于null的流程。按照workInProgress的子节点的属性给current树创建出相同的子节点。
然后在commit阶段结束后,current树会被渲染到页面上,fiberRoot对象的current会指回到current树,具体如下图
第二次更新
再点击一次数字,触发state的第二次更新,还是看之前的createWorkInProgress方法。
这时候,因为两颗树都已经构建完成,所以current.alternate是存在的。所以之后每次通过beginWork 触发createWorkInProgress调用时,都会一致地走入else里面的逻辑,也就是直接复用现成的节点。
这也就是双缓冲机制实现节点复用的方法。
更新链路要素
React源码解析第一篇分析了首次渲染的链路,更新的链路其实跟首次渲染大致一样。
首次渲染可以理解为一种特殊的更新,ReactDOM.render,setState,useState一样,都是一种触发更新的姿势。这些方法发起的调用链路很相似,是因为它们最后“殊途同归”,都会通过创建update对象来进入同一套更新工作流。
按demo的流程来,点击数字之后,会触发一个dispatchAction方法,在该方法中,会完成update对象的创建
update创建完成之后,会跟首次渲染一样,进入updateContainer方法(首次渲染链路中的update会在这个方法里创建),这里主要是两个方法
enqueueUpdate(current, update);
scheduleUpdateOnFiber(current, lane, eventTime);
-
enqueueUpdate:将update入队。每一个Fiber节点都会有一个属于它自己的updateQueue,用于存储多个更新,这个updateQueue是以链表的形式存在的。在render阶段,updateQueue的内容会成为render阶段计算Fiber节点的新state的依据。 -
scheduleUpdateOnFiber:调度update。这个方法后面紧跟的就是performSyncWorkOnRoot所触发的render阶段。
这里有一个点需要提示一下:dispatchAction中,调度的是当前触发更新的节点,这一点和挂载过程需要区分开来。在挂载过程中,updateContainer会直接调度根节点。其实,对于更新这种场景来说,大部分的更新动作确实都不是由根节点触发的,而render阶段的起点则是根节点。所以在scheduleUpdateOnFiber 中,有这样一个方法
它会从当前Fiber节点开始,向上遍历直至根节点,并将根节点返回。所以,我们说React的更新流程,是从根节点开始,重新遍历整个fiber树,这也是为什么我们平时的性能优化的重点都在减少组件的重新render上。
在scheduleUpdateOnFiber中,还有一个重要的判断,那就是对于同步和异步的判断逻辑。
之前我们分析同步的首次渲染流程的时候,走的是performSyncWorkOnRoot方法,但是对于异步模式,会运行ensureRootIsScheduled方法。来看下一段核心逻辑
if (newCallbackPriority === SyncLanePriority) {
// 同步更新的 render 入口
newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
} else {
// 将当前任务的 lane 优先级转换为 scheduler 可理解的优先级
var schedulerPriorityLevel = lanePriorityToSchedulerPriority(newCallbackPriority);
// 异步更新的 render 入口
newCallbackNode = scheduleCallback(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
}
从这段逻辑中我们可以看出,React会以当前更新任务的优先级类型为依据,决定接下来是调度 performSyncWorkOnRoot还是performConcurrentWorkOnRoot。这里调度任务用到的函数分别是 scheduleSyncCallback和scheduleCallback,这两个函数在内部都是通过调用 unstable_scheduleCallback方法来执行任务调度的。这个方法是Scheduler(调度器)中导出的一个核心方法。
Scheduler的核心能力,就是让fiber架构实现了时间切片与优先级调度这两个核心特征。
时间切片
先来了解一下时间切片到底是做了什么事情?
import React from 'react';
function App() {
const arr = new Array(1000).fill(0);
return (
<div className="App">
<div className="container">
{
arr.map((i, index) => <p>{`测试文本第${index}行`}</p>)
}
</div>
</div>
);
}
上面的代码就是渲染1000条p标签到页面上,当我们使用ReactDOM.render进行渲染,因为它是一个同步的过程,所有的链路都会在一个宏任务里执行掉。根据不同用户电脑和浏览器的性能不同,这个宏任务的执行时间,可能是100ms、200ms、300ms甚至更多。因为js线程和渲染线程是互斥的,在执行这个比较长时间的宏任务时,我们浏览器的渲染线程将被阻塞。我们知道浏览器的刷新频率为60Hz也就是说每16.6ms就会刷新一次,这种长时间的宏任务导致的渲染线程阻塞,将会产生明显的卡顿、掉帧。
而时间切片,就是把这段需要较长时间运行的宏任务“切”开,变成一段段尽量保证运行时间在浏览器刷新间隔时间之下的宏任务。给渲染线程留出时间,保证渲染的流畅度。我们来看两张图,第一张是同步模式下的调用栈
下一张是把ReactDOM.render调用改为createRoot,用Concurrent(异步)模式来进行渲染
我们可以看到,本来一个长时间的“大任务”被切成了一个个短时间的“小任务”。
时间切片是如何实现的?
根据上文对scheduleUpdateOnFiber的分析,在同步的模式下,React会调用performSyncWorkOnRoot,在这个链路下,会通过workLoopSync方法来循环创建Fiber节点、构建Fiber树。
function workLoopSync() {
// 若 workInProgress 不为空
while (workInProgress !== null) {
// 针对它执行 performUnitOfWork 方法
performUnitOfWork(workInProgress);
}
}
这是一个无法中断的过程,开始了就无法停止。
而在异步的模式下,React会调用performConcurrentWorkOnRoot,通过renderRootConcurrent调用 workLoopConcurrent来构建Fiber树。
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
我们可以发现,异步的方法里,其实就只是多了一个shouldYield()方法,当shouldYield()为true的时候,while循环将停止,将主线程让给渲染线程。
shouldYield的本体其实也是调度器里导出的一个方法Scheduler.unstable_shouldYield,方法很简单。源码地址
export function unstable_shouldYield() {
return getCurrentTime() >= deadline;
}
就是当当前时间大于deadline这个当前时间切片的到期时间时,就返回true,停止workLoopConcurrent循环。
我们来看下deadline是怎么定义的
deadline = getCurrentTime() + yieldInterval;
getCurrentTime()就是当前时间,而yieldInterval是一个常量,5ms,源码地址
const yieldInterval = 5;
所以说,时间切片的间隔是5ms(实际应该都是比5ms稍大,因为必须等当前的fiber节点构建完成之后,才会通过shouldYield()方法判断是否到期)
当workLoopConcurrent循环中断之后,React会重新发起调度(setTimeout或者MessageChannel方式),检查是否存在事件响应、更高优先级任务或其他代码需要执行,如果有则执行,如果没有则重新创建工作循环workLoopConcurrent,执行剩下的工作中Fiber节点构建。
优先级调度
在更新链路中,无论是scheduleSyncCallback还是scheduleCallback,最终都是通过调用 unstable_scheduleCallback来发起调度的。
unstable_scheduleCallback是Scheduler导出的一个核心方法,它将结合任务的优先级信息为其执行不同的调度逻辑。源码地址
function unstable_scheduleCallback(priorityLevel, callback, options) {
// 获取当前时间
var currentTime = getCurrentTime();
// 声明 startTime,startTime 是任务的预期开始时间
var startTime;
// 以下是对 options 入参的处理
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
// 若入参规定了延迟时间,则累加延迟时间
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}
// timeout 是 expirationTime 的计算依据
var timeout;
// 根据 priorityLevel,确定 timeout 的值
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
// 优先级越高,timout 越小,expirationTime 越小
var expirationTime = startTime + timeout;
// 创建 task 对象
var newTask = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
if (enableProfiling) {
newTask.isQueued = false;
}
// 若当前时间小于开始时间,说明该任务可延时执行(未过期)
if (startTime > currentTime) {
// 将未过期任务推入 "timerQueue"
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// 若 taskQueue 中没有可执行的任务,而当前任务又是 timerQueue 中的第一个任务
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// 那么就派发一个延时任务,这个延时任务用于检查当前任务是否过期
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
// else 里处理的是当前时间大于 startTime 的情况,说明这个任务已过期
newTask.sortIndex = expirationTime;
// 过期的任务会被推入 taskQueue
push(taskQueue, newTask);
......
// 执行 taskQueue 中的任务
requestHostCallback(flushWork);
}
return newTask;
}
unstable_scheduleCallback的主要工作是针对当前任务创建一个task,然后结合startTime信息将这个task推入timerQueue或taskQueue,最后根据timerQueue和taskQueue的情况,执行延时任务或即时任务。
这里需要知道几个概念
- startTime:任务的开始时间。
- expirationTime:这是一个和优先级相关的值,expirationTime 越小,任务的优先级就越高。
- timerQueue:一个以 startTime 为排序依据的小顶堆,它存储的是 startTime 大于当前时间(也就是待执行)的任务。
- taskQueue:一个以 expirationTime 为排序依据的小顶堆,它存储的是 startTime 小于当前时间(也就是已过期)的任务。
堆是一种特殊的完全二叉树。如果对一棵完全二叉树来说,它每个结点的结点值都不大于其左右孩子的结点值,这样的完全二叉树就叫“小顶堆”。小顶堆自身特有的插入和删除逻辑,决定了无论我们怎么增删小顶堆的元素,其根节点一定是所有元素中值最小的一个节点。
我们来看下核心逻辑
if (startTime > currentTime) {
// 将未过期任务推入 "timerQueue"
newTask.sortIndex = startTime;
push(timerQueue, newTask);
// 若 taskQueue 中没有可执行的任务,而当前任务又是 timerQueue 中的第一个任务
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
......
// 那么就派发一个延时任务,这个延时任务用于检查当前任务是否过期
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
// else 里处理的是当前时间大于 startTime 的情况,说明这个任务已过期
newTask.sortIndex = expirationTime;
// 过期的任务会被推入 taskQueue
push(taskQueue, newTask);
......
// 执行 taskQueue 中的任务
requestHostCallback(flushWork);
}
若判断当前任务是未过期任务,那么该任务会在sortIndex属性被赋值为startTime后,被推入timerQueue。taskQueue里存储的是已过期的任务,peek(taskQueue) 取出的任务若为空,则说明taskQueue为空、当前并没有已过期任务。在没有已过期任务的情况下,若当前任务(newTask)就是timerQueue中需要最早被执行的未过期任务,那么unstable_scheduleCallback会通过调用requestHostTimeout,为当前任务发起一个延时调用。
注意,这个延时调用(也就是handleTimeout)并不会直接调度执行当前任务——它的作用是在当前任务到期后,将其从 timerQueue中取出,加入taskQueue中,然后触发对flushWork的调用。真正的调度执行过程是在flushWork中进行的。flushWork中将调用workLoop,workLoop会逐一执行taskQueue中的任务,直到调度过程被暂停(时间片用尽,将重新发起Task调度)或任务全部被清空。
当下React发起Task调度的姿势有两个:setTimeout、MessageChannel。在宿主环境不支持MessageChannel的情况下,会降级到setTimeout。但不管是setTimeout还是MessageChannel,它们发起的都是异步任务(宏任务,将在下次eventLoop中被调用)。
感谢
如果本文对你有所帮助,请帮忙点个赞,感谢!