上一篇介绍了如何创建元素和如何渲染元素,但是递归调用的问题在于,它可能导致主线程长时间阻塞,从而导致糟糕的用户体验。如果元素树很大,呈现过程可能会花费太长时间,从而导致其他任务的延迟,例如处理用户输入或保持动画流畅。所以我们将把工作分解成小单元,在我们完成每个单元后,如果有其他需要做的事情,我们将让浏览器中断渲染。这就类似操作系统的进程调度。
进程调度算法(补充内容,可跳过)
常见的操作系统进程调度算法包括以下几种:
1. 先来先服务(FCFS): 按照进程到达的先后顺序进行调度。优点是简单易实现,但可能会导致长作业等待时间过长,无法适应实时任务。
- 优点:简单易实现。
- 缺点:可能导致长作业等待时间过长,无法适应实时任务。
2. 最短作业优先(SJF): 优先调度执行预计执行时间最短的进程。优点是能够最大程度地减少平均等待时间,但无法应对长作业等待时间过长的问题,可能会导致饥饿现象。
- 优点:能够最大程度减少平均等待时间。
- 缺点:无法应对长作业等待时间过长的问题,可能导致饥饿现象。
3. 优先级调度: 为每个进程分配一个优先级,按照优先级高低进行调度。优点是能够根据进程的重要性和紧迫性进行灵活调度,但可能会导致优先级反转问题,即低优先级进程长时间等待高优先级进程的释放。
- 优点:能根据进程的重要性和紧迫性进行灵活调度。
- 缺点:可能导致优先级反转问题,即低优先级进程长时间等待高优先级进程的释放。
4. 时间片轮转(RR): 将CPU时间划分为固定长度的时间片,每个进程按照时间片顺序轮流执行。优点是公平地分配CPU时间,适用于多任务环境,但可能会导致上下文切换频繁,增加系统开销。
- 优点:公平地分配CPU时间,适用于多任务环境。
- 缺点:可能导致上下文切换频繁,增加系统开销。
5. 多级反馈队列调度: 将进程划分为多个队列,每个队列有不同的优先级和时间片大小。优先级较高的队列时间片较短,优先级较低的队列时间片较长。进程按照队列顺序进行调度,当一个进程用完时间片后,将被移到下一个优先级较低的队列。优点是能够兼顾短作业和长作业的调度需求,但需要根据实际情况合理设置队列数量和时间片大小。
- 优点:兼顾短作业和长作业的调度需求。
- 缺点:需要根据实际情况合理设置队列数量和时间片大小。
步骤3:事件循环
React不再使用requestIdleCallback了。现在它使用调度器包。但是对于这个用例,它在概念上是相同的。
requestIdleCallback
是一个 Web API,用于在浏览器空闲时执行函数。它允许开发人员在主线程空闲时执行计算密集型或延迟较高的任务,而不会阻塞用户界面的响应。- 回调函数传递一个
IdleDeadline
对象作为参数,该对象提供了有关当前空闲时间的信息。开发人员可以根据空闲时间决定执行何种任务,以避免阻塞主线程。 timeRemaining
是IdleDeadline
对象的一个方法,它用于获取当前空闲时间的估计值。timeRemaining
方法返回一个表示当前空闲时间的数字,单位为毫秒。这个值可以用来判断在当前空闲时间内还有多少时间可用于执行任务。
简易事件循环函数
javaScript
let nextUnitOfWork = null // 默认初始下一执行单位为空
// 事件循环函数
function workLoop(deadline) {
let shouldYield = false // 标识是否需要让出执行权
// 当有下一个执行单位并且不需要让出执行权的时,循环执行
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1 // 判断剩余时间是否充足
}
requestIdleCallback(workLoop) // 请求空闲回调
}
requestIdleCallback(workLoop) // 当空闲时开启事件循环函数
function performUnitOfWork(nextUnitOfWork) {
// TODO:需要一个执行工作单元函数,需要执行当前工作并且返回下一个工作单元
}
步骤4:Fiber
相比传统二叉树,fiber树增加了父节点索引和兄弟节点索引,提供了更好的可中断和恢复的能力,使得 React 在渲染过程中可以根据需要中断任务,执行其他优先级更高的任务,然后再恢复之前的任务,从而提升用户体验。通过使用 Fiber 树,React 可以将渲染任务分解成多个优先级较低的小任务,并在主线程空闲时执行这些小任务。这样可以确保即使在处理复杂渲染时,也能及时响应用户的交互操作。
在渲染过程中,我们将创建根 Fiber,并将其设置为 nextUnitOfWork。其余的工作将在 performUnitOfWork 函数中进行,对于每个 Fiber,我们将执行以下三个操作:
- 将元素添加到 DOM 中。
- 为元素的子元素创建 Fiber。
- 选择下一个工作单元(next unit of work)。
这些步骤将在每个 Fiber 上执行,直到完成整个渲染过程。
那么fiber是如何选择下一个工作单元的呢?
按照 当前节点 - > 子节点 - > 兄弟节点 - > 父亲兄弟节点 的顺序决定下一个工作单元,这就是为什么每个纤维都有一个链接到它的第一个子节点、下一个兄弟节点和它的父节点。例如上图<p>
节点完成后因为没有子节点,所以转到<a>
。<a>
节点结束后即没有子节点也没有兄弟节点则转到叔叔节点<h2>
让我们把上述逻辑写进代码~
javaScript
// 我们将之前的render函数改造一下,把创建dom部分抽取出来
function render(element, container) {
// TODO:设定下一个工作单元
}
// 从原render函数中抽取出的部分
function createDom(fiber) {
const dom =
fiber.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
const isProperty = (key) => key !== "children";
Object.keys(fiber.props)
.filter(isProperty)
.forEach((name) => {
dom[name] = fiber.props[name];
});
return dom;
}
// 执行工作单元函数
function performUnitOfWork(fiber) {
// 判断fiber树是否已经创建dom
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 判断fiber树是否有父节点
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom);
}
const elements = fiber.props.children;
let index = 0;
let prevSibling = null; // 上一兄弟节点
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
};
if (index === 0) {
fiber.child = newFiber;
} else {
prevSibling.sibling = newFiber; // 上一兄弟节点索引到新创建节点
}
prevSibling = newFiber; // 将新创建的节点设为上一兄弟节点
index++;
}
// 如果有子节点返回子节点
if (fiber.child) {
return fiber.child;
}
let nextFiber = fiber;
// 查找兄弟节点,叔叔节点
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling;
}
nextFiber = nextFiber.parent;
}
}