写给本身看的React源码解析(三):Fiber架构下更新链路以及Concurrent模式实现原理

前言

本文内容涉及到不少渲染链路中的原理以及源码方法,因此在看本文以前,须要对于Reactrender渲染流程有大体的了解。不清楚的同窗能够先看个人第一篇源码解析文章。react

写给本身看的React源码解析(一):你的React代码是怎么渲染成DOM的? git

本文主要解析fiber架构更新链路的双缓冲模式以及Concurrent模式下时间切片优先级的实现原理。github

双缓冲

双缓冲模式的主要用处,是可以帮咱们较大限度地实现Fiber节点的复用,减小性能方面的开销。浏览器

在以前的文章中,咱们知道在首次渲染的时候会建立出两颗树,current树与workInProgress树。current树与workInProgress树,其实就是两套缓冲数据:当current树被渲染到页面上时,全部的数据更新都会由workInProgress树来承接。workInProgress树将会在内存里悄悄地完成全部改变,直到下次进行渲染的commit阶段执行完毕以后,fiberRoot对象的current会指向workInProgress树,workInProgress树就会变成渲染到页面上的current树。性能优化

咱们用一个实际例子来帮助理解:markdown

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树。app

第一次更新

点击一次数字,咱们进入第一次的更新流程。重点看beginWork调用链路中的createWorkInProgress方法。异步

上图中,workInProgress树下面的子节点的current.alternate对应的就是current树的子节点,可是current树目前没有子节点,因此为null,进入等于null的流程。按照workInProgress的子节点的属性给current树建立出相同的子节点。ide

而后在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。这里调度任务用到的函数分别是 scheduleSyncCallbackscheduleCallback,这两个函数在内部都是经过调用 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_scheduleCallbackScheduler导出的一个核心方法,它将结合任务的优先级信息为其执行不一样的调度逻辑。源码地址

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推入timerQueuetaskQueue,最后根据timerQueuetaskQueue的状况,执行延时任务或即时任务。

这里须要知道几个概念

  • 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后,被推入timerQueuetaskQueue里存储的是已过时的任务,peek(taskQueue) 取出的任务若为空,则说明taskQueue为空、当前并无已过时任务。在没有已过时任务的状况下,若当前任务(newTask)就是timerQueue中须要最先被执行的未过时任务,那么unstable_scheduleCallback会经过调用requestHostTimeout,为当前任务发起一个延时调用。

注意,这个延时调用(也就是handleTimeout)并不会直接调度执行当前任务——它的做用是在当前任务到期后,将其从 timerQueue中取出,加入taskQueue中,而后触发对flushWork的调用。真正的调度执行过程是在flushWork中进行的。flushWork中将调用workLoopworkLoop会逐一执行taskQueue中的任务,直到调度过程被暂停(时间片用尽,将从新发起Task调度)或任务所有被清空。

当下React发起Task调度的姿式有两个:setTimeoutMessageChannel。在宿主环境不支持MessageChannel的状况下,会降级到setTimeout。但不论是setTimeout仍是MessageChannel,它们发起的都是异步任务(宏任务,将在下次eventLoop中被调用)。

感谢

若是本文对你有所帮助,请帮忙点个赞,感谢!

相关文章
相关标签/搜索