React 之从 requestIdleCallback 到时间切片

简介: 在上篇《React 之 requestIdleCallback 来了解一下》,我们讲解了 requestIdleCallback 这个 API,它可以实现在浏览器空闲的时候执行代码,这就与 React 的理念非常相似,都希望执行的时候不影响到关键事件,比如动画和输入响应,但因为兼容性、requestIdleCallback 定位于执行后台和低优先级任务、执行频率等问题,React 在具体的实现中并未采用 requestIdleCallback,本篇我们来讲讲 React 是如何实现类似于 requestIdleCallback,在空闲时期执行代码的效果,并由此讲解时间切片的背后实现。

setTimeout 模拟


MDN 提供了一种使用 setTimeout 的兜底实现:

window.requestIdleCallback = window.requestIdleCallback || function(handler) {
  let startTime = Date.now();
  return setTimeout(function() {
    handler({
      didTimeout: false,
      timeRemaining: function() {
        return Math.max(0, 50.0 - (Date.now() - startTime));
      }
    });
  }, 1);
}

但这个并不是 polyfill ,因为它在功能上并不相同。使用 setTimeout() 并不能像 requestIdleCallback 一样做到在空闲时段执行代码。


postMessage 介绍


那如何实现空闲时期执行呢?React 早期采用的是 requestAnimationFrame + postMessage 来实现的,requestAnimationFrame 我们在 《React 之 requestAnimationFrame 执行机制探索》介绍过,我们简单说说 postMessage。


引用 MDN 的介绍


window.postMessage() 方法可以安全地实现跨源通信。


它的简单使用示例如下:

window.addEventListener("message", (e) => {console.log(e.data)}, false);
window.postMessage('Hello World!')


React v16 实现(rAF + postMessage)


那 React 是怎么实现的呢?我们可以查看 React 16.0.0 版本里的实现,这里为了方便理解,将代码极度简化了:

// Polyfill requestIdleCallback.
var scheduledRICCallback = null;
var frameDeadline = 0;
// 假设 30fps,一秒就是 33ms
var activeFrameTime = 33;
var frameDeadlineObject = {
  timeRemaining: function() {
    return frameDeadline - performance.now();
  }
};
var idleTick = function(event) {
    scheduledRICCallback(frameDeadlineObject);
};
window.addEventListener('message', idleTick, false);
var animationTick = function(rafTime) {
  frameDeadline = rafTime + activeFrameTime;
  window.postMessage('__reactIdleCallback$1', '*');
};
var rIC = function(callback) {
  scheduledRICCallback = callback;
  requestAnimationFrame(animationTick);
  return 0;
};

逻辑很简单,rIC 就是 requestIdleCallback 的简写,rIC 函数执行的时候,调用 requestAnimationFrame(animationTick),animationTick 会被传入当前帧执行的时间(rafTime),我们假设要保持最低的 30fps,一帧就是 33ms,我们就可以得知这帧最晚应该在 frameDeadline = rafTime + activeFrameTime前结束。


然后我们通过 postMessage 进行通信,通信的内容并不重要,重点是浏览器会被推入一个宏任务,也就是 idleTick 函数,我们通过 frameDeadline - performance.now()就可以算出这帧还剩多少时间,然后将包含这个信息的对象(frameDeadlineObject)传入 callback 函数,由此实现了 requestIdleCallback 的模拟。


那你可能会问,“空闲时期执行”这个效果怎么就实现了呢?我们之想要实现空闲时期执行,想要达到的效果是不阻碍浏览器对动画和用户输入的处理,我们没有直接执行 callback 回调,而是用 postMessage 推入到任务列表中,浏览器就可以响应更高优先级的动画或者用户输入,等执行完再去我们推入的任务。这效果不就一样达成了吗?


那你可能会问,这不就是延时执行?为什么不用 setTimeout 呢?


确实也可以,但当你设置 setTimeout(fn, 0)的时候,虽然想表达的意思是马上执行,但浏览器在实现的时候会有一个最低 4ms 的间隔,如果你想在浏览器中实现 0ms 延时的定时器,应该借助 postMessage,这也是 MDN 文档给的建议


取消使用 rAF


随着代码的不断变更,实现又发生了一些变化,比如 React 在 v16.2.0 版本取消了使用 requestAnimationFrame,为什么会取消 rAF 呢?这个 PR 进行了回答。


回顾之前的实现,我们是通过猜测这帧的结束时间,然后在这帧大概结束的时候再让出线程,但实际上没有必要这样实现。我们的想法是,将 React 的更新做成一个任务列表,我们不直接遍历这个任务列表,而是每执行几个任务,就调用 postMessage,告诉浏览器,我不遍历任务了,线程给你,你先忙,等处理完再接着遍历任务列表中剩下的任务。这样我们就不用管这帧是什么时候结束。为了方便起见,我们将这种实现方式叫做 message loop。


而且为了提高性能和电池寿命,在大多数浏览器里,当 requestAnimationFrame() 运行在后台标签页或者隐藏的<iframe> 里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。这对 React 的执行也会有一定的影响。


那 message loop 具体是怎么实现的呢?我们先额外介绍一下 MessageChannel


MessageChannel 介绍


web 通信(web messaging)有两种方式,一种是跨文档通信(cross-document messaging),也就是我们熟知的 window.postMessage(),常被用于与 iframe 之间的通信,一种是通道通信(channel messaging),也就是我们现在要介绍的。


MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据,使用示例如下:

var channel = new MessageChannel();
channel.port1.onmessage = (e) => {console.log(e.data)}
channel.port2.postMessage('Hello World')

了解过事件循环的同学应该知道宏任务与微任务,它们又被称为“macro task”和“micro task” ,像 promise 就属于微任务,setTimeout 属于宏任务,MessageChannel 跟 setTimeout 一样,也属于宏任务。知道这点就够了。


React v18 实现(message loop)


写这篇文章的时候,React 的版本是 v18.2.0,代码为了方便阅读,同样做了简化:

// 记录 callback
let scheduledHostCallback;
let isMessageLoopRunning = false;
let getCurrentTime = () => performance.now();
let startTime;
// rIC 更名为 requestHostCallback
function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null);
};
function performWorkUntilDeadline() {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    startTime = currentTime;
    const hasTimeRemaining = true;
    let hasMoreWork = true;
    try {
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
};

可以看到在当前的实现中,原来的 rIC 改名为 requestHostCallback,requestHostCallback 函数执行的时候,会调用 schedulePerformWorkUntilDeadline,它只做了一件事情,就是 postMessage,通知浏览器等忙完自己的事情,再执行 performWorkUntilDeadline


performWorkUntilDeadline 中会执行 scheduledHostCallback(hasTimeRemaining, currentTime),scheduledHostCallback 就是传入 requestHostCallback 的 callback 函数,它返回一个布尔值告诉 performWorkUntilDeadline 是否还有更多任务,如果有,那就调用 schedulePerformWorkUntilDeadline,告诉浏览器等会再接着干,由此实现了任务小量执行,不断让出线程,从而保证浏览器能够及时处理动画或者用户输入。


那 scheduledHostCallback 到底是什么呢?我们可以知道它就是调用 requestHostCallback 时传入的函数,但这个被传入的函数执行的内容是什么呢?


既然都已经看了 React 的 Scheduler 源码,来都来了,我们就再多看一点。


我们会发现 requestHostCallback 虽然有三处被调用,但传入的函数都是 flushWork 这个函数:

requestHostCallback(flushWork);

所以 performWorkUntilDeadline 中执行的 scheduledHostCallback(hasTimeRemaining, currentTime),其实就相当于执行 flushWork(hasTimeRemaining, currentTime)


flushWork 做了什么呢?我们将 flushWork 相关的代码拿出来,这里同样做了简化:

function flushWork(hasTimeRemaining, initialTime) {
   return workLoop(hasTimeRemaining, initialTime);
}
var currentTask;
function workLoop(hasTimeRemaining, initialTime) {
  currentTask = taskQueue[0];
  while (currentTask != null) {
    if (
      currentTask.expirationTime > initialTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      break;
    }
    const callback = currentTask.callback;
    callback();
    taskQueue.shift()
    currentTask = taskQueue[0];
  }
  if (currentTask !== null) {
    return true;
  } else {
    return false;
  }
}
// 默认的时间切片
const frameInterval = 5;
function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    return false;
  }
  return true;
}

我们可以看到在 flushWork 中,我们调用的是 workLoop 函数,在 workLoop 中,我们取出任务列表中的第一个任务,然后判断任务是否过期,以及是否应该让出线程(shouldYieldToHost)


在 shouldYieldToHost 中,getCurrentTime() 表示当前的时间,startTime 我们是在收到 postMessage 的消息时执行 performWorkUntilDeadline 函数的时间,performWorkUntilDeadline 中,我们执行 scheduledHostCallback(hasTimeRemaining, currentTime),也就是 flushWork(hasTimeRemaining, initialTime),也就是 workLoop(hasTimeRemaining, initialTime),workLoop 中我们又进行了 shouldYieldToHost 的判断。


那你可能要说,从 performWorkUntilDeadline 到 shouldYieldToHost,这中间全是同步操作,就没隔多少时间呀,确实如此,执行第一个任务的时候,getCurrentTime() 和  startTime 确实没有隔多少时间,耗时的任务是 taskQueue 中的任务,也就是  const callback = currentTask.callback; callback()这里。


等跑完第一个任务,我们又会判断一次 shouldYieldToHost,如果还不超过 5ms(默认的时间切片时间),我们就接着执行任务,再判断 shouldYieldToHost,直到任务列表空了,或者过了 5ms。

如果过了 5ms,发现还有任务,hasMoreWork 是 true,然后会执行 schedulePerformWorkUntilDeadline,也就是再执行一个 port.postMessage(null),这样就让出了线程,让浏览器能够处理动画和用户输入,等浏览器处理完,它会执行 port1.onmessage 推入的 performWorkUntilDeadline 任务,在 performWorkUntilDeadline 中又接着遍历执行任务列表,就这样每执行 5ms 的任务,就让出线程,直到完成任务列表中的所有任务。


时间切片


现在我们终于得知了 React 时间切片的真相。


React 把 React 的更新操作做成了一个个任务,塞进了 taskQueue,也就是任务列表,如果直接遍历执行这个任务列表,纯同步操作,执行期间,浏览器无法响应动画或者用户的输入,于是借助 MessageChannel,依然是遍历执行任务,但当每个任务执行完,就会判断过了多久,如果没有过默认的切片时间(5ms),那就再执行一个任务,如果过了,那就调用 postMessage,让出线程,等浏览器处理完动画或者用户输入,就会执行 onmessage 推入的任务,接着遍历执行任务列表。


一个完整可用的简化版


// 用于模拟代码执行耗费时间
const sleep = delay => {
  for (let start = Date.now(); Date.now() - start <= delay;) {}
}
// performWorkUntilDeadline 的执行时间,也就是一次批量任务执行的开始时间,通过现在的时间 - startTime,来判断是否超过了切片时间
let startTime;
let scheduledHostCallback;
let isMessageLoopRunning = false;
let getCurrentTime = () => performance.now();
const taskQueue = [{
  expirationTime: 1000000,
  callback: () => {
    sleep(30);
    console.log(1)
  }
}, {
  expirationTime: 1000000,
  callback: () => {
    sleep(30);
    console.log(2)
  }
}, {
  expirationTime: 1000000,
  callback: () => {
    sleep(30);
    console.log(3)
  }
}]
function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true;
    schedulePerformWorkUntilDeadline();
  }
}
const channel = new MessageChannel();
const port = channel.port2;
function performWorkUntilDeadline() {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    startTime = currentTime;
    const hasTimeRemaining = true;
    let hasMoreWork = true;
    try {
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      console.log('hasMoreWork', hasMoreWork)
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  }
};
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline = () => {
  port.postMessage(null);
};
function flushWork(hasTimeRemaining, initialTime) {
  return workLoop(hasTimeRemaining, initialTime);
}
let currentTask;
function workLoop(hasTimeRemaining, initialTime) {
  currentTask = taskQueue[0];
  while (currentTask != null) {
    console.log(currentTask)
    if (
      currentTask.expirationTime > initialTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      break;
    }
    const callback = currentTask.callback;
    callback();
    taskQueue.shift()
    currentTask = taskQueue[0];
  }
  if (currentTask != null) {
    return true;
  } else {
    return false;
  }
}
const frameInterval = 5;
function shouldYieldToHost() {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
    return false;
  }
  return true;
}
requestHostCallback(flushWork)


最后


从讲解 FPS 到 requestAnimationFrame 再到 requestIdleCallback,再到本篇的时间切片,算是为 React 系列的第一个大篇章 —— Scheduler 篇拉开了序幕。我们开始讲 React 的重要组成部分,Scheduler(调度器)。


目录
相关文章
|
3月前
|
前端开发 JavaScript 算法
React.useState 的更新频率及原因解析
【8月更文挑战第24天】
95 0
|
前端开发 JavaScript
react 数组下标来作为 react组件中的key
react 数组下标来作为 react组件中的key
114 0
|
6月前
|
JavaScript 前端开发 算法
React中的DOM diff算法是如何工作的
React的DOM diff算法通过对比新旧虚拟DOM树找到最小更新策略,提高组件更新效率。它生成并比较虚拟DOM,按类型、属性和&quot;key&quot;逐节点检查。不同类型节点直接替换,属性不同则更新属性,相同则递归比较子节点。确定DOM操作后批量执行,减少对真实DOM的访问,优化性能。然而,在复杂场景下可能有性能问题,可借助shouldComponentUpdate、memo或PureComponent等进行优化。
|
6月前
|
前端开发 JavaScript 开发者
很基础但很重要,React 元素本质
很基础但很重要,React 元素本质
|
6月前
|
前端开发 JavaScript 容器
React 之元素与组件的区别
React 之元素与组件的区别
51 1
|
6月前
|
XML JavaScript 前端开发
说说React jsx转换成真实DOM的过程?
说说React jsx转换成真实DOM的过程
68 0
|
前端开发
React中的无限渲染问题总结
React中的无限渲染问题总结 前言 无限渲染情况汇总分析 第一种情况 第二种情况 第三种情况:state和setState分别在useEffect的依赖和回调中(前两种只与useState有关) 第四种:缺失依赖 第五种:函数(对象)作为依赖 第六种:将数组(对象)作为依赖 第七种:将对象作为依赖 总结 参考
533 0
React中的无限渲染问题总结
|
12月前
|
前端开发
React基础语法04-循环数组渲染数据的方法
React基础语法04-循环数组渲染数据的方法
134 0
|
前端开发
react如何在数组中手动添加一个对象
react如何在数组中手动添加一个对象
193 0
|
算法 前端开发 JavaScript
为什么 React 的 Diff 算法不采用 Vue 的双端对比算法?
都说“双端对比算法”,那么双端对比算法,到底是怎么样的呢?跟 React 中的 Diff 算法又有什么不同呢? 要了解这些,我们先了解 React 中的 Diff 算法,然后再了解 Vue3 中的 Diff 算法,最后讲一下 Vue2 中的 Diff 算法,才能去比较一下他们的区别。
421 0