React 之 requestIdleCallback 来了解一下

简介: React 之 requestIdleCallback 来了解一下

语法介绍


requestIdleCallback,其中 idle 用作形容词的时候,表示无事可做的、闲置的、空闲的。


简写为 rIC引用 MDN 的介绍


window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期(idle periods)被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。


使用语法如下:

var handle = window.requestIdleCallback(callback[, options])

其中:handle 表示返回的 ID,可以把它传入 Window.cancelIdleCallback() 方法来结束回调

callback 表示一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。


options 表示可选的配置参数,目前只有一个 timeout,如果指定了 timeout,并且有一个正值,而回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队。


基本使用


举个使用的例子:

requestIdleCallback((deadline) => {
    console.log(deadline)
}, {timeout: 1000})

打印结果如下:

image.png

其中 14 表示返回的 ID,注意打印的 deadline 参数,它有一个 didTimeout 属性,表示回调是否在超时时间前已经执行,它还有一个 timeRemaining() 函数,表示当前闲置周期的预估剩余毫秒数,我们尝试调用下 timeRemaining:

requestIdleCallback((deadline) => {
    console.log(deadline.timeRemaining())
}, {timeout: 1000})

打印结果如下:

image.png

其中 9 表示返回的 ID,35.6 表示预估的剩余毫秒数。


你可能会想,怎么会剩这么多呢?60Hz下,一帧不才 16.7ms?


我们接着往下看。


执行时机


现在我们来思考一个问题,requestIdleCallback 的执行时机是什么时候?到底什么才是空闲时期(idle periods)?


为了探究这个问题,我们查下 requestIdleCallback 的 W3C 规范


在完成一帧中的输入处理、渲染和合成之后,线程会进入空闲时期(idle period),直到下一帧开始,或者队列中的任务被激活,又或者收到了用户新的输入。requestIdleCallback 定义的回调就是在这段空闲时期执行:

image.png

这样的空闲时期通常会在动画(active animations)和屏幕刷新(screen updates)中频繁出现,但一般时间都非常短。(比如:在 60Hz 的设备下小于 16ms)


另外一个空闲时期的例子是当没有屏幕刷新出现的时候,在这种情况下,因为没有任务出现限制空闲时期的时间,但为了避免出现不可预知的任务(比如用户输入)导致用户可感知的延迟,空闲时期会被限制为最长 50ms,当一个 50ms 空闲时期结束后,如果还是空闲状态,就会再开启一个 50ms 的空闲时期:

image.png

我的总结就是:


如果存在屏幕刷新,浏览器会计算当前帧剩余时间,如果有空闲时期,就会执行 requestIdleCallback 回调


如果不存在屏幕刷新,浏览器会安排连续的长度为 50ms 的空闲时期


为什么会是 50ms 呢?这是因为有研究报告说,用户输入之后,100 毫秒内的响应会被认为是瞬时的,将空闲时期限制为 50ms 后,浏览器依然有 50ms 可以响应用户输入,不会让用户产生可感知的延迟。


执行次数


我在查资料的时候,看到一些文章说,requestIdleCallback 的 fps 是 20ms,这句话真的是看的我一脸懵逼,首先 fps 在之前的文章介绍过,它表示“每秒显示帧数”,是帧率的测量单位,我们可以说游戏此时的 fps 是 20,但说一个 JS API 的 fps 是 20,就很奇怪,而且 fps 和 ms 表示的都是单位,fps 是 20ms 的用法也很奇怪。


当然我们可以理解出,作者想表达的是 requestIdleCallback 每秒会被执行 20 次。


其次是我找了很久也没有找到出处,只有 React 的一个 Issue 下看到有人提到了:


MAY BE OFFTOPIC:


requestIdleCallback is called only 20 times per second - Chrome on my 6x2 core Linux machine, it's not really useful for UI work.


requestAnimationFrame is called more often, but specific for the task which name suggests.


这里也只是说 requestIdleCallback 每秒只会被执行 20 次,但具体是怎么测试的呢?我并没有找到相关 demo。


但如果是说 requestIdleCallback 每秒执行 20 次,倒是也可以想到,因为在不存在屏幕刷新的情况下,空闲周期是连续的 50ms,如果都是空闲周期,那一秒确实是 20 次。


但这并不能说明什么,因为 requestIdleCallback 和 requestFrameAnimation 的用法是不一样的,我们用 requestFrameAnimation 的时候通常是做动画,每帧执行一个样式修改,但 requestIdleCallback 是用来处理低优先级的任务的,我们会把任务做成一个队列,只要还有空闲时间,我们就持续执行队列里的任务,所以 requestIdleCallback 虽然调用次数少,但在一次 requestIdleCallback 中,我们可能会完成很多任务。


队列任务处理


现在我们来聊聊使用 requestIdleCallback 是如何处理队列任务的:

// 参考 MDN Background Tasks API 这篇文章
// https://developer.mozilla.org/zh-CN/docs/Web/API/Background_Tasks_API#example
let taskHandle = null;
let taskList = [
  () => {
    console.log('task1')
  },
  () => {
    console.log('task2')
  },
  () => {
    console.log('task3')
  }
]
function runTaskQueue(deadline) {
  console.log(`deadline: ${deadline.timeRemaining()}`)
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
    let task = taskList.shift();
    task();
  }
  if (taskList.length) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
  } else {
    taskHandle = 0;
  }
}
requestIdleCallback(runTaskQueue, { timeout: 1000 })

我们首先声明了一个 taskList 任务列表,然后声明了一个 runTaskQueue 函数,在函数中,只要 deadline 的 timeRemaining 还有时间或者已经超时了,任务列表里还有任务,我们就持续执行任务,在这样一个例子里,因为空余时间足够,三个任务会在同一帧执行。 执行结果如下:

image.png

而如果任务时间比较久,浏览器会自动放到下个空闲时期执行,我们写个 sleep 函数模拟一下:

const sleep = delay => {
  for (let start = Date.now(); Date.now() - start <= delay;) {}
}
let taskHandle = null;
let taskList = [
  () => {
    console.log('task1')
    sleep(50)
  },
  () => {
    console.log('task2')
    sleep(50)
  },
  () => {
    console.log('task3')
    sleep(50)
  }
]
function runTaskQueue(deadline) {
  console.log(`deadline: ${deadline.timeRemaining()}`)
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
    let task = taskList.shift();
    task();
  }
  if (taskList.length) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
  } else {
    taskHandle = 0;
  }
}
requestIdleCallback(runTaskQueue, { timeout: 1000 })

执行结果如下:

image.png

根据 deadline 被 console 了三次,我们可以判断出任务分别被放在了三个空闲时期执行


避免操作 DOM


requestIdleCallback 非常适合用于一些低优先级的任务,比如你不希望数据统计相关的代码阻碍了代码执行,那就可以放到 requestIdleCallback 中执行。


但是要注意一点的是


避免在空闲回调中改变 DOM。 空闲回调执行的时候,当前帧已经结束绘制了,所有布局的更新和计算也已经完成。如果你做的改变影响了布局,你可能会强制停止浏览器并重新计算,而从另一方面来看,这是不必要的。如果你的回调需要改变 DOM,它应该使用Window.requestAnimationFrame()来调度它。


搭配 rAF


关于 requestIdleCallback 如何处理任务以及如何搭配 requestAnimationFrame 处理 DOM,我们写一个示例代码:

// 代码改自:https://developer.mozilla.org/zh-CN/docs/Web/API/Background_Tasks_API#%E5%85%85%E5%88%86%E5%88%A9%E7%94%A8%E7%A9%BA%E9%97%B2%E5%9B%9E%E8%B0%83
import ReactDOM from 'react-dom/client';
import React from 'react';
const root = ReactDOM.createRoot(document.getElementById('root'));
let logFragment = null;
function log(text) {
  if (!logFragment) {
    logFragment = document.createDocumentFragment();
  }
  let el = document.createElement("div");
  el.innerHTML = text;
  logFragment.appendChild(el);
}
function getRandomIntInclusive(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min; //含最大值,含最小值
}
class App extends React.Component {
  componentDidMount() {
    let taskHandle = null;
    let statusRefreshScheduled = false;
    let taskList = [
      () => {
        log('task1')
      },
      () => {
        log('task2')
      },
      () => {
        log('task3')
      }
    ]
    function addTask() {
      let n = getRandomIntInclusive(1, 3);
      for (var i = 0; i < n; i++) {
        enqueueTask(((i, n) => {
          return () => log(`task num ${i+1} of list ${n}`)
        })(i, n));
      }
    }
    function enqueueTask(fn) {
      taskList.push(fn);
      // taskHandle 表示对当前处理中任务的一个引用
      if (!taskHandle) {
        taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000 });
      }
    }
    function runTaskQueue(deadline) {
      console.log(`deadline: ${deadline.timeRemaining()}`)
      while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
        let task = taskList.shift();
        task();
        scheduleStatusRefresh();
      }
      if (taskList.length) {
        taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
      } else {
        taskHandle = 0;
      }
    }
    // 安排 DOM 的改变
    function scheduleStatusRefresh() {
      if (!statusRefreshScheduled) {
        requestAnimationFrame(updateDisplay);
        statusRefreshScheduled = true;
      }
    }
    // 负责绘制内容
    let logElem = document.getElementById("log");
    function updateDisplay(time) {
      if (logFragment) {
        logElem.appendChild(logFragment);
        logFragment = null;
      }
      statusRefreshScheduled = false;
    }
    document.getElementById("startButton").addEventListener("click", addTask, false);
  }
  render() {
    return (
      <div>
        <div id="startButton">
          开始
        </div>
        <div id="log">
        </div>
      </div>
    )
  }
}
root.render(<App />);

代码执行效果如下:

image.png

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4a8b6cc0a98a4fa5a0019154cc57bcd9~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp


现在我们来分析下这个过程:


当用户点击按钮的时候,执行 addTask函数,我们会随机创建 1~3 个任务函数,调用 enqueueTask,在enqueueTask中,将任务函数推入 taskList,然后执行一个 requestIdleCallback(runTaskQueue),告诉浏览器空闲的时候,跑下任务列表,在 runTaskQueue中,我们会判断,如果当前有空闲时间,或者有超时任务,我们就依此取出列表中的任务函数进行调用,并且执行 DOM 更新,也就是 scheduleStatusRefresh 函数,当没有时间的时候,我们会再调用 requestIdleCallback(runTaskQueue),告诉浏览器等空闲了,接着执行,由此实现了,只要浏览器有空闲时间并且有任务,任务列表就会一直执行。


而在 scheduleStatusRefresh 中,我们使用 requestAnimationFrame 进行 DOM 更新,在创建的任务函数中,我们只是记录了要更新的内容,在 updateDisplay中才真正进行了更新。避免了在 requestIdleCallback 中改变 DOM。


效果比较


你可能会想,这么麻烦,还不如直接更新呢?


那么我们可以基于当前的这个例子,再添加一种直接更新的情况,依此作为比较


为了凸显更新的影响,我们加一个 CSS3 loading 效果:

<style>
.loading{
  width: 150px;
  height: 4px;
  border-radius: 2px;
  margin: 0 auto;
  margin-top:100px;
  position: relative;
  background: lightgreen;
  -webkit-animation: changeBgColor 1.04s ease-in infinite alternate;
}
.loading span{
  display: inline-block;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: lightgreen;
  position: absolute;
  margin-top: -7px;
  margin-left:-8px;
  -webkit-animation: changePosition 1.04s ease-in infinite alternate;
}
@-webkit-keyframes changeBgColor{
  0% {
    background: lightgreen;
  }
  100%{
    background: lightblue;
  }
}
@-webkit-keyframes changePosition{
  0% {
    background: lightgreen;
  }
  100% {
    margin-left: 142px;
    background: lightblue;
  }
}
</style>
<div class="loading">
  <span></span>
</div>

它的动画效果如下:

image.png

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e2406ecbe1184d0cb0b8c1a4953e6f60~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp


现在我们再加一个按钮,当点击这个按钮的时候,我们不使用 requestIdleCallback,直接更新 DOM:

function addTaskSync() {
  // 注意这里我们将任务量提升到了至少 50000 个
  let n = getRandomIntInclusive(50000, 100000);
  for (var i = 0; i < n; i++) {
    log(`task num ${i+1} of list ${n}`)
  }
  scheduleStatusRefresh();
}
document.getElementById("startButtonSync").addEventListener("click", addTaskSync, false);

现在我们点击一下这个按钮,我们会发现 loading 动画会卡顿一下:

image.png

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/80d0b6107bdd4ee9b2bf3b8a9bf97ca9~tplv-k3u1fbpfcp-zoom-in-crop-mark:1512:0:0:0.awebp


但是同样的任务量,使用 requestIdleCallback 则不会阻塞动画的运行。


兼容性与 polyfill


requestIdleCallback 的兼容性如下:

image.png

MDN 也提供了 requestIdleCallback 的 polyfill 写法:

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 一样实现在空闲时段执行代码,但至少可以将每次传递的运行时间限制为不超过 50 毫秒。


与 React 的关系


你可能会问,requestIdleCallback 与 React 到底有什么关系呢?


其实没什么关系,只是一个理念借鉴,React 早期确实使用过 requestIdleCallback,但现在也不使用了,不过这篇已经太长了,下篇我们接着讲 requestIdleCallback。


目录
相关文章
|
Web App开发 前端开发 JavaScript
React 之 requestAnimationFrame 执行机制探索
React 之 requestAnimationFrame 执行机制探索
526 0
|
存储 JavaScript 前端开发
揭秘Vue 2中的$nextTick:等待DOM更新的神奇时刻!
揭秘Vue 2中的$nextTick:等待DOM更新的神奇时刻!
|
前端开发 API 调度
React 之从 requestIdleCallback 到时间切片
在上篇《React 之 requestIdleCallback 来了解一下》,我们讲解了 requestIdleCallback 这个 API,它可以实现在浏览器空闲的时候执行代码,这就与 React 的理念非常相似,都希望执行的时候不影响到关键事件,比如动画和输入响应,但因为兼容性、requestIdleCallback 定位于执行后台和低优先级任务、执行频率等问题,React 在具体的实现中并未采用 requestIdleCallback,本篇我们来讲讲 React 是如何实现类似于 requestIdleCallback,在空闲时期执行代码的效果,并由此讲解时间切片的背后实现。
633 0
|
8月前
|
数据采集 消息中间件 JavaScript
浏览器渲染揭秘:从加载到显示的全过程;浏览器工作原理与详细流程
了解浏览器工作原理与流程,能有效帮助前端开发与性能优化。 博客不应该只有代码和解决方案,重点应该在于给出解决方案的同时分享思维模式,只有思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
|
12月前
|
JavaScript iOS开发
多格式、功能强大的移动端日期选择插件
rolldate是一款多格式、功能强大的移动端日期选择插件。该插件可以在移动端实现iOS样式的日期时间选择效果。支持多种时间格式,使用better-scroll作为滑动插件,支持自定义语言和回调函数等,功能非常强大。
689 63
|
Web App开发 缓存 监控
如何使用 Chrome DevTools 的 Performance 面板进行页面加载性能分析?
如何使用 Chrome DevTools 的 Performance 面板进行页面加载性能分析?
|
11月前
|
设计模式 消息中间件 供应链
前端必须掌握的设计模式——发布订阅模式
发布订阅模式(Publish-Subscribe Pattern)是一种设计模式,类似于观察者模式,但通过引入第三方中介实现发布者和订阅者的解耦。发布者不再直接通知订阅者,而是将消息发送给中介,由中介负责分发给订阅者。这种方式提高了异步支持和安全性,适合复杂、高并发场景,如消息队列和流处理系统。代码实现中,通过定义发布者、订阅者和中介接口,确保消息的正确传递。此模式在前端开发中广泛应用,例如Vue的数据双向绑定。
|
12月前
|
JavaScript
可自由配置的jQuery消息提示框插件toast
jquery.toast.js是一款可自由配置的jQuery消息提示框插件。该消息提示框可以自定义背景和前景色,提示框的位置,提示框的显示时间,提示框的动画效果等。
368 2
|
移动开发 前端开发 应用服务中间件
React两种路由模式的实现原理
React两种路由模式的实现原理
343 3
|
存储 前端开发 JavaScript
深入理解React组件的生命周期与Hooks
【10月更文挑战第7天】深入理解React组件的生命周期与Hooks
541 0