React 之 requestIdleCallback 来了解一下

9,673 阅读7分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

本篇是 React 基础与进阶系列第 9 篇,关注专栏

前言

本篇我们讲讲 requestIdleCallback 这个 API,就算抛开 React,2022 年了,这个 API 多少也是要知道一点的。

语法介绍

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 />);

代码执行效果如下:

8.gif

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

当用户点击按钮的时候,执行 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>

它的动画效果如下:

9.gif

现在我们再加一个按钮,当点击这个按钮的时候,我们不使用 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 动画会卡顿一下:

10.gif

但是同样的任务量,使用 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。

React 系列

  1. React 之 createElement 源码解读
  2. React 之元素与组件的区别
  3. React 之 Refs 的使用和 forwardRef 的源码解读
  4. React 之 Context 的变迁与背后实现
  5. React 之 Race Condition
  6. React 之 Suspense
  7. React 之从视觉暂留到 FPS、刷新率再到显卡、垂直同步再到16ms的故事
  8. React 之 requestAnimationFrame 执行机制探索

React 系列的预热系列,带大家从源码的角度深入理解 React 的各个 API 和执行过程,全目录不知道多少篇,预计写个 50 篇吧。