【React源码笔记6】- scheduleWork2

React | 2020-07-13 01:27:08 448次 2次

本篇继续进行调度过程分析,进入 unstable_scheduleCallback 进行 flushWorkworkLoop 操作,执行传入的回调 performConcurrentWorkOnRoot 异步方法或者 flushSyncCallbackQueueImpl 队列中是 performSyncWorkOnRoot 同步方法(同步需要批量更新),进而进入 performUnitOfWork 开始 beginWork 也就是开始下个阶段 render 阶段的执行。

再回顾下上一篇主要做的事情就是根据不同的任务模式 expirationTime === Sync 判断是立即渲染同步任务还是调度异步模式任务,调度模式里面根据任务优先级进行传入相关回调,到这里根据 requestHostCallback 开始利用空闲时间或者分片调度任务。


一、unstable_scheduleCallback

这个方法中就涉及到了浏览器空闲时间调度的概念,react 源码中没有直接使用浏览器自带的 requestIdleCallback 方法,而是自己实现了一个这样的机制。这里面关于优先级的到期时间,如果过了这个时间还没有被执行,就要立即执行(尽管可能还是阻塞浏览器的渲染,引起卡顿,所以这个调度并不是想象中那么完美)。开始还有个误区,以为 react 会知道业务中代码的运行时间更加动态调整,现在发现不是,而是根据不同的事件类型来自动指定一个固定时间。

//优先级 | 同步 or 异步渲染 | 传入一个 对象 timeout 指定过期时间
function unstable_scheduleCallback(priorityLevel, callback, options) {
  //获取当前运行时间
  var currentTime = getCurrentTime();

  var startTime;
  var timeout;
  //如果传入了配置则使用配置的  反之需要自己计算一下
  if (typeof options === 'object' && options !== null) {
    //延迟的时间,暂无发现哪里使用
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
    timeout =
      typeof options.timeout === 'number'
        ? options.timeout
        : timeoutForPriorityLevel(priorityLevel);
  } else {
    //根据优先级获取对应的过期时间
    //高优先级-1 | 阻塞型 250ms | 普通优先级 5s | 低优先级 10s
    timeout = timeoutForPriorityLevel(priorityLevel);
    startTime = currentTime;
  }

  //执行到这个方法时才去计算的时间+优先级事件需要的对应时间(或者手动指定)
  var expirationTime = startTime + timeout;

  //包装任务
  var newTask = {
    id: taskIdCounter++, //初始1
    callback,   // 传入的回调 performSyncWorkOnRoot | performConcurrentWorkOnRoot
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };
  ...
  //只有 startTime = currentTime + delay 指定延迟的情况才会触发
  if (startTime > currentTime) {
    // 当这是个延迟的task,需要被加入timerQueue,按照startTime排序
    newTask.sortIndex = startTime;
    // 将新任务保存在小顶堆中
    // 小顶堆按 sortIndex(如果相同则比较 id)排序
    // 所以堆顶任务为sortIndex最小(或id最小)任务
    push(timerQueue, newTask);
    // 取出 第一个元素
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // 所有任务都延迟了,当前任务是优先级最高的
      if (isHostTimeoutScheduled) {
        // 清除定时器
        cancelHostTimeout();
      } else {
        isHostTimeoutScheduled = true;
      }
      //延迟的直接通过定时器触发,startTime - currentTime 为何不写成 delay?
      requestHostTimeout(handleTimeout, startTime - currentTime);
    }
  } else {
    // 否则就是正常的需要被执行的任务,按照expirationTime作为排序依据
    newTask.sortIndex = expirationTime;
    push(taskQueue, newTask);
    ...
    // 执行回调方法,如果已经再工作需要等待一次回调的完成
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      //对 requestIdleCallback 模拟实现 
      requestHostCallback(flushWork);
    }
  }
  //返回这个任务
  return newTask;
}


二、push 小顶堆

push 这个方法涉及到小顶堆的插入操作,调用 siftUp 来创建,并通过 sortIndex(如果相同)则继续根据 id 比较。

function push(heap, node) {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}
 
function siftUp(heap, node, i) {
  let index = i;
  while (true) {
    const parentIndex = (index - 1) >>> 1; //相当于 parseInt((index-1) / 2)
    const parent = heap[parentIndex];
    if (parent !== undefined && compare(parent, node) > 0) {
      // The parent is larger. Swap positions.
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // The parent is smaller. Exit.
      return;
    }
  }
}
 
function compare(a, b) {
  // Compare sort index first, then task id.
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

let s = []
push(s, {sortIndex: 9})
push(s, {sortIndex: 12 })
push(s, {sortIndex: 17})
push(s, {sortIndex: 30})
push(s, {sortIndex: 50})
push(s, {sortIndex: 20})
push(s, {sortIndex: 60})
push(s, {sortIndex: 65})
push(s, {sortIndex: 4})
push(s, {sortIndex: 19})

最终生成:[4, 9, 17, 12, 19, 20, 60, 65, 30, 50]的顺序小顶堆,如下图:

微信截图_20200713220654.png

三、requestHostCallback

这个方法本来以为就不变了,参考模拟实现。结果目前这个版本中又是一个新的方案,之前通过 requestAnimationFrame 来模拟的,现在的方案是通过高频调用 postMessage 来调度任务,为了在每一帧执行更多的任务,提升运行效率,但是目前这种方案无疑加剧了浏览器资源的争夺,没有真正的利用浏览器空闲时间(个人理解)。这里面通过 MessageChannel 两个通道通信的机制。

const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;

requestHostCallback = function(callback) {
  scheduledHostCallback = callback;
  //默认false
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    //prot2发送消息,只有prot1才可以接到
    port.postMessage(null);
  }
};

时间分片:为什么一个 MessageChannel 可以梭哈调度,其实并不是,还有一个 shouldYield 在 workLoopConcurrent 方法中,来进行时间分片,时间不够了则中断,当返回true 的时候(比如时间片 5ms 到期或者有更高优先级任务插入进来),循环被中断,一个时间分片就结束了,浏览器将重获控制权,当前中断的节点信息被记录在 workInProgress 上,方便下次继续执行,时间分片我理解为是属于 Reconcilerrender阶段),伴随着时间分片而产生多个 fiber 任务单元。

多任务分片taskQueue 初始时候会被存入一个任务,通过 workLoop 来执行(清空)任务队列。接着时间分片的概念,如果有过期的任务会继续执行调度逻辑,从而任务队列中会再增加一个任务,react 执行 port.postMessage 发起了一个事件,进入到 performWorkUntilDeadline 这里继续执行回调(workLoop 这里面也会有一个 5ms 期限判断,不会无限执行,过期需要交换控制权),如果还有剩余的任务会返回一个 true,所以可以判断 hasMoreWork 为 true 则认为还有任务会继续发一个 port.postMessage 。那么浏览器获取控制权就在这个间隙内,去执行浏览器自身的渲染工作,也就是每隔 5ms 就交还一次执行权,达到一种高频率的这种调用,貌似会比之前的方式效果更好,之前的方式有可能会长时间阻塞。

const performWorkUntilDeadline = () => {
  //这个就是传入的任务回调,有任务再继续
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    //结束时间 5ms 之后(5ms的执行时间)
    deadline = currentTime + yieldInterval;
    const hasTimeRemaining = true;
    try {
      //查看当前是否还有任务 进行前面传入的回调执行:flushWork
      const hasMoreWork = scheduledHostCallback(
        hasTimeRemaining,
        currentTime,
      );
      //没有任务了
      if (!hasMoreWork) {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      } else {
        //还有任务则通过消息机制再触发,这样再到下一次执行期间的这段时间控制权交还给浏览器渲染
        port.postMessage(null);
      }
    } catch (error) {
      //错误继续调度,但是会抛出错误,这样我们可以捕获
      port.postMessage(null);
      throw error;
    }
  } else {
    isMessageLoopRunning = false;
  }
  // Yielding to the browser will give it a chance to paint, so we can
  // reset this.
  needsPaint = false;
};

现在可见 requestHostCallback 这个方法变得极其精简,理解上要比之前那版要相对容易,接下来主要看下 scheduledHostCallback 也就是 flushWork 这个方法。


四、flushWork 

这里面看主要的逻辑就是执行了 wookLoop,其中 currentPriorityLevel 最终要被恢复为初始值为普通优先级,这种操作调度中大量出现:

//刷新调度队列,执行调度任务
function flushWork(hasTimeRemaining, initialTime) {
  ...
  //这里重置,之前根据这个变量判断控制是否调用requestHostCallback,可能是防止无限被调度
  isHostCallbackScheduled = false;
  //和延迟有关
  if (isHostTimeoutScheduled) {
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }
  isPerformingWork = true;
  //暂时记录下之前的优先级值,执行完workLoop后恢复
  const previousPriorityLevel = currentPriorityLevel;
  try {
    //忽略。。
    if (enableProfiling) {
      ...
    } else {
      // 执行工作循环
      return workLoop(hasTimeRemaining, initialTime);
    }
  } finally {
    //这里将优先级恢复为默认值
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    ...
  }
}


五、advanceTimers 

这里先看一个 advanceTimers 方法,用的地方还挺多,这里面取出延迟的任务,对没有传入回调的任务直接移除,但是目前没有发现延迟的任务,伊萨尔小老弟告诉我说这是兼容上一版本中的 requestAnimationFrame 模式的,比如浏览器至于后台时,这个方法就会暂停,属于一个致命的缺陷对于 react 的这种设计而言。

先熟悉下它的大概做的事情吧,方便后续的阅读,将 timerQueue 中将要执行的任务放入到 taskQueue 任务中并重新创建堆:

// 遍历timerQueue 将延迟的任务取出(目前没发现延迟任务啊, timer 一直为null)
function advanceTimers(currentTime) {
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      // 没有传入回调的就移除,就是 performConcurrentWorkOnRoot 异步方法
      //或者 flushSyncCallbackQueueImpl 队列中是 performSyncWorkOnRoot 同步方法
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // 先移除,下面再push
      pop(timerQueue);
      //需要被执行,排序按照过期时间(当前时间+过期的时间) 和被创建时的sortIndex条件类型对应起来
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
      ...
    } else {
      // Remaining timers are pending. 等待状态
      return;
    }
    timer = peek(timerQueue);
  }
}


六、workLoop

workLoop 这里面也会有一个 5ms 期限判断,不会无限执行,过期需要交换控制权。并且这里面的 taskQueue 任务队列(小顶堆结构)不仅仅包含过期分片的任务,还包含比如同时 ReactDOM.render 多次产生的这种任务都在这里。

//工作循环的开始,react16模拟实现文章里面就是从这个方法开始的,这里是可以进行多任务的taskQueue
function workLoop(hasTimeRemaining, initialTime) {
  //initialTime就是传入的currentTime时间
  let currentTime = initialTime;
  advanceTimers(currentTime);
  //取出头任务
  currentTask = peek(taskQueue);
  //有任务
  while (
    currentTask !== null && !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    //任务没过期但是没有剩余时间了 跳出循环。下面返回 true 证明还有任务,下个时间段再继续执行
    if (
      currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost())
    ) { break; }
    //performConcurrentWorkOnRoot 异步方法或者 flushSyncCallbackQueueImpl 回调,如果不存在下面直接丢掉 pop
    const callback = currentTask.callback;
    if (callback !== null) {
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      //超时
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      ...
      //performConcurrentWorkOnRoot.bind(null, root)所以这里是一个参数
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      // performConcurrentWorkOnRoot 中有个情况会返回函数
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
        ...
      } else {
        ...
        //清除任务
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      advanceTimers(currentTime);
    } else {
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
  // 是否还有更多任务
  if (currentTask !== null) {
    return true;
  } else {
    ...
    return false;
  }
}


总结

react 的第一阶段,调度基本上就这些内容,它的主要目的是将产生的高优先级的任务推去 render 阶段优先执行。调度过程中会创建任务队列 taskQueue(不是更新队列,反正我开始是容易搞混)并且非阻塞执行,因为会进行 5ms 的一个时间期限判断,然后交还执行权等待下次再 workLoop 直到任务为空。

调度核心:GUI渲染线程与JS引擎是互斥的,所以一直 5ms 发起一个宏任务,根据事件循环机制避免 js 长时间占用。

正常执行:比如初始一系列操作后产生一个 taskQueue 任务,通过执行 callback 进入 render 阶段,这里面开始进行不停的 performUnitOfWork 单元工作(这里是针对 fiber 节点而言),如果到时间需要交还浏览器执行权时候会再次执行调度 ensureRootIsScheduled 并且创建新的 taskQueue 任务这样循环下去直到 performUnitOfWork 中不再有返回值,通过 wip 记录中断位置。

高优先级插队:上面说的是一种正常的情况,如果这个过程中通过 update 产生了高优先级的任务,会打断当前正在进行的任务,通过 cancelCallback 方法(取消的是 unstable_scheduleCallback 方法中返回这个任务),将 callback 置为 null,这样workLoop 中这个任务就不存在,先去执行更高优先级任务,保证高优先级任务先去 performUnitOfWork,等待高优先级更新完成后,回过头来再执行低优先级,重要的是它是需要再从 root 根开始进行工作(高优先级执行的这个过程中不知做了什么事)所以从根再执行一遍,wip 也是会被重置。

补录:高优先级任务如果插队了,那它可能没有前序计算结果,或者前序计算结果不完全。高优先级不依赖于前序结果,只保证它自身先完成任务渲染给用户看,以后再进行一般优先级的渲染,这次会将所有任务包括之前已经完成的高优先级任务,而这次的baseState 会以上次被跳过的优先级的前序计算结果为基准,再进行计算。也就是说高优先级任务执行完仍然存在更新队列中,并且还会被再执行一次。要保证结果一致性。

微信图片_20200730233013.png


调度更新就是调度的 rootFiber,不是调度 fiber 节点,节点只会被进行分片执行和调度无关。


关于 调度更新(schedule)批量更新(batched) 的关系(借鉴卡颂老师的话)

调用 this.setState 会在 fiber 上创建 update, 经过一顿操作后最后会返回一个 rootFiber

rootFiber 会被调度更新。所以**调度更新**是针对 rootFiber 的,而不是某一个 fiber 节点。

如果某个 fiber 上创建了多个同优先级的 update(比如一次事件回调内调用多次 this.setState),那么同样的,经过一顿操作后最后会返回多个 rootFiber

但是这些 rootFiber 由于优先级(lane)是相同的,他们只会被**调度一次更新**。也就是说只会进入一次 render - commit 阶段。 这就叫 batchedUpdate

Legacy mode 时,batchedUpdate 是通过在触发创建 update 的回调函数前在上下文中赋值一个标记判断的 executionContext (以前是有个 isBatchingUpdates 变量) ,所以才会有 unstable_batchedUpdate 这个 API 开放给开发者使用。是否批量更新的源头来自 scheduleUpdateOnFiber 方法:

if (expirationTime === Sync) {
    if (...) {
       ...
    } else {
      //批量更新的模式下进入调度,但是同时多个setState操作会被return掉,确保异步更新
      ensureRootIsScheduled(root);
      schedulePendingInteractions(root, expirationTime);
      //如果处于非批量更新的状态下会进入这里立即执行了
      //(比如定时器中的多个set操作,除非手动调用那个批量钩子,修改 executionContext 的值)
      // 这里也就是为什么定时器中连续的 set 操作会是同步,每次都执行任务了
      if (executionContext === NoContext) {
        //执行任务
        flushSyncCallbackQueue();
      }
    }
}

//批量更新的方法 通过 unstable_batchedUpdates 方式使用
function batchedUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= BatchedContext;

  try {
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;

    if (executionContext === NoContext) {
      // Flush the immediate callbacks that were scheduled during this batch
      flushSyncCallbackQueue();
    }
  }
}

所以批量更新的情景下进入 ensureRootIsScheduled 中会有个判断 existingCallbackNode 存在且更新时间优先级一样的情况下直接 return 掉,非批量更新时(比如定时器中的 set)会立即执行任务,进入调度时 existingCallbackNode 就不存在了,所以也不会 returnsetState 中传入的回调函数为什么可以取到最新值呢,因为它是在 render 之后被执行的 commitUpdateQueue

补充批量更新:内置事件默认批量更新模式,batchedEventUpdates$1 中对 executionContext (默认为0)赋值,执行完 commit阶段重置为0。批量模式:discreteUpdates$1去清空多个任务 flushSyncCallbackQueue,只执行一次 performSyncWorkOnRoot;非批量模式:调度多次,每一次都进行 flushSyncCallbackQueue 执行多次 performSyncWorkOnRoot。

Concurrent Mode 时是否 batchedUpdate 是根据优先级(lane)决定的,相近时间差被抹平,不需要标记变量,所以完全是自动的,开发者不需要手动介入。


下一个阶段进入 render 阶段,会执行 performConcurrentWorkOnRoot 异步方法或者 flushSyncCallbackQueueImpl 同步方法中 performSyncWorkOnRoot。这些方法中会再次执行调度,为了继续执行被高优先级打断的任务或者被时间分片的任务。

2人赞

分享到: