后台任务 window.requestIdleCallback 方法的使用

818 阅读9分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第9天,点击查看活动详情

介绍

window.requestAnimationFrame 方法类似,提供了由用户代理(浏览器)决定,在空闲时间自动执行队列任务的能力。这样使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。

浏览器的主线程以其事件循环队列为中心,渲染 Document 上待更新展示的内容,执行页面待运行的 JavaScript 脚本,接收来自输入设备的事件,以及分发事件给需要接收事件的元素。此外,事件循环队列还处理与操作系统的交互、浏览器自身用户界面的更新等等。

所以,防止在事件队列中出现卡顿是很重要的。在过去,除了编写尽可能高效的代码和将尽可能多的工作移交给 Worker 之外,没有其他可靠的方法可以做到这一点。Window.requestIdleCallback 允许浏览器告诉您的代码可以安全使用多少时间而不会导致系统延迟,从而有助于确保浏览器的事件循环平稳运行。

Window.requestIdleCallback 旨在为代码提供一种与事件循环协作的方式,以确保系统充分利用其潜能,不会过度分配任务,从而导致延迟或其他性能问题。

语法

// 回调函数执行:当前帧有空闲时间,或者指定时间到了
let id = requestIdleCallback(function someHeavyComputation(deadline) {
  while((deadline.timeRemaining() > 0 || deadline.didTimeout) && thereIsMoreWorkToDo) {
    doWorkIfNeeded(); // 一定会在将来某个比较空闲的时间(或者在指定时间过期后)得到反复执行
  }

  if(thereIsMoreWorkToDo) {
    id = requestIdleCallback(someHeavyComputation);
  }
}, { timeout: 1000 });

window.cancelIdleCallback(id);

callback

  • 一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个 IdleDeadline 对象的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态。
    • IdleDeadline.timeRemaining() 只读,返回一个浮点值,且会动态更新,因此可以不断检查这个属性,如果还有剩余时间的话,就不断执行某些任务。一旦这个属性等于 0,就把任务分配到下一轮 requestIdleCallback
    • IdleDeadline.didTimeout 只读,返回一个布尔值,表示指定的时间是否过期。这意味着,如果回调函数由于指定时间过期而触发,那么你会得到两个结果。
      • timeRemaining 方法返回 0
      • didTimeout 属性等于 true

options 可选

  • 包括可选的配置参数。具有如下属性:
    • timeout:函数一般会按先进先调用的顺序执行,如果指定了 timeout,且回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队(即使这样有可能对性能产生负面影响)。负值会被忽略。

返回一个 ID,可以把它传入 Window.cancelIdleCallback 方法来结束回调。如果未指定 timeout 选项,只有当前帧的运行时间小于 16.66ms 时,函数 callback 才会执行,否则,就推迟到下一帧,如果下一帧也没有空闲时间,就推迟到下下一帧,以此类推。强烈建议使用 timeout 选项进行必要的工作,否则可能会在触发回调之前经过几秒钟。可以在一个 idle callback 中调用 Window.cancelIdleCallback,以便在下一次通过事件循环之前调度另一个回调。

使用 setTimeout() 填充

因为后台任务 API 还是相当新的,而你的代码可能需要在那些不仍不支持此 API 的浏览器上运行。你可以把 setTimeout() 用作回调选项来做这样的事。下面这个函数并不是 polyfill ,因为它在功能上并不相同;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);
}

window.cancelIdleCallback = window.cancelIdleCallback || function(id) {
  clearTimeout(id);
}

每次调用 timeRemaining(),它都会从开始的 50 毫秒中减去已逝去的时间,来确定还剩余的时间。虽然这个填充程序不会像真正的 requestIdleCallback() 将自己限制在当前事件循环传递中的空闲时间内,但它至少将每次传递的运行时间限制为不超过 50 毫秒。尽管效率不高,但也可以在不支持后台任务 API 的浏览器上运行了。

示例

在这个示例中,我们使用 requestIdleCallback() 来在浏览器空闲时运行高耗时、低优先级的任务,requestAnimationFrame() 安排文档内容的更新。

HTML 内容

这里创建了一个盒子 (ID "Container") 来显示操作进度,因为我们没法知道解码会用多长时间。进度框用一个 <progress> 元素展示进度,随着它标签部分的变化,会呈现进度的数字信息。还创建了一个次要的盒子 (ID "logBox") 来展示文本输出。

<div id="container">
  <progress id="progressBarElem" value="0"></progress>
  <button class="button" onclick="decodeTechnoStuff()">Start</button>
  <div class="label counter">Task <span id="currentTaskNumberElem">0</span> of <span id="totalTaskCountElem">0</span>
  </div>
</div>
<div id="logBox">
  <div class="logHeader">Log</div>
  <div id="logElem"></div>
</div>
  • totalTaskCountElem 用于插入我们在进度框显示状态中创建的任务总数。
  • currentTaskNumberElem 是我们用来呈现到当前为止处理过的任务数的元素。
  • progressBarElem 来呈现到当前为止处理过任务的百分比。
  • startButtonElem 是开始按钮。
  • logElem 显示记录过的文本信息。

定义变量

let taskList = [];
let totalTaskCount = 0;
let currentTaskNumber = 0;
let taskHandle = null;

let logFragment = null;
let statusRefreshScheduled = false;
  • taskList 等待执行的任务列表,是一个对象数组, 每个对象代表一个待运行的任务。
  • totalTaskCount 是一个已被添加到队列的任务数量计数器,只会增大,不会减小。我们用它计算总工作量进度的百分比值。
  • currentTaskNumber 用于追踪到现在为止已处理了多少任务。
  • taskHandle 对当前处理中任务的一个引用。
  • logFragment 当渲染下一帧,我们的记录方法都会生成一个 DocumentFragment 来创建添加到记录的内容,并保存到 logFragment 中 DocumentFragment
  • statusRefreshScheduled 我们用它来追踪我们是否已经为即将到来的帧安排了状态显示框的更新,所以我们每一帧只执行一次。

管理任务队列

接下来,我们管理需要执行的任务。我们将创建一个先进先出(FIFO)的任务队列,在空闲回调期间,如果时间允许,我们将执行这个队列。

排队任务

首先,我们需要一个函数把任务排成队列,以便将来执行。

function enqueueTask(taskHandler, taskData) {
  taskList.push({
    handler: taskHandler,
    data: taskData
  });

  totalTaskCount++;

  if (!taskHandle) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000 });
  }

  scheduleStatusRefresh();
}

enqueueTask() 接受两个参数作为参数

  • taskHandler 一个函数,被调用来处理任务。
  • taskData 一个对象(object),被当作输入参数传递给taskHandler,以允许任务接收自定义数据。
  1. 为了把任务排成队列,我们把一个对象(objectpushtaskList 数组;此对象包含 taskHandlertaskData 的值(命名分别是 handlerdata)。
  2. 然后把队列里的任务总数 totalTaskCount 增加(我们不会在从队列中移除任务时减少 totalTaskCount)。
  3. 接下来,我们来检查是否已经创建了一个空闲回调;如果 taskHandle0,我们调用 requestIdleCallback() 去创建一个。它被配置为调用一个叫 runTaskQueue() 的函数,它的 timeout1 秒,因此,即使没有任何实际可用的空闲时间,它也至少会每秒运行一次。

执行任务

空闲回调处理方法 runTaskQueue() 将在浏览器确定有足够的可用空闲时间或 1 秒的timeout 到期时被调用。这个方法的作用是执行队列中的任务。

function runTaskQueue(deadline) {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) && taskList.length) {
    let task = taskList.shift();
    currentTaskNumber++;

    task.handler(task.data);
    scheduleStatusRefresh();
  }

  if (taskList.length) {
    taskHandle = requestIdleCallback(runTaskQueue, { timeout: 1000} );
  } else {
    taskHandle = 0;
  }
}

runTaskQueue() 的核心是一个循环,只要有剩余时间(通过检查 IdleDeadline.timeRemaining 来确认它大于 0),或者已经达到了 timeout 期限(deadline.didTimeout 值为真),且任务列表中有任务就会一直持续。

  1. 对队列中每个我们有时间要执行的任务,做以下操作:
    1. 把任务对象 task 从队列中移除。
    2. currentTaskNumber 增加来追踪我们已执行的任务数量。
    3. 调用任务处理方法 task.handler,并把任务的数据对象 task.data 传入其中。
    4. 调用方法 scheduleStatusRefresh(),更新进度的变化。
  2. 当时间耗尽,如果列表里还有任务,我们再次调用 requestIdleCallback() 使我们可以在下次有可用空闲时间时继续运行这些任务。如果队列是空的,我们将把 taskHandle 设置为 0 来表示我们没有回调任务了。这样,下一次 enqueueTask() 被调用时,我们就知道要请求一个回调了。

更新状态显示

在空闲回调中改变 DOM 是不安全的。作为替代,我们使用 requestAnimationFrame() 来让浏览器在可以安全地更新显示时通知我们。

安排显示的更新

调用 scheduleStatusRefresh() 函数来安排 DOM 的改变。

function scheduleStatusRefresh() {
    if (!statusRefreshScheduled) {
      requestAnimationFrame(updateDisplay);
      statusRefreshScheduled = true;
  }
}

通过检查 statusRefreshScheduled 的值来得知我们是否已经安排了一个显示更新。如果值为 false,我们调用 requestAnimationFrame() 来安排一个更新。

更新显示

updateDisplay() 函数负责绘制进度框的内容和记录。当 DOM 的状况安全,我们可以在下次渲染过程中申请改变时,浏览器会调用它。

function updateDisplay() {
  let scrolledToEnd = logElem.scrollHeight - logElem.clientHeight <= logElem.scrollTop + 1;

  if (totalTaskCount) {
    if (progressBarElem.max != totalTaskCount) {
      totalTaskCountElem.textContent = totalTaskCount;
      progressBarElem.max = totalTaskCount;
    }

    if (progressBarElem.value != currentTaskNumber) {
      currentTaskNumberElem.textContent = currentTaskNumber;
      progressBarElem.value = currentTaskNumber;
    }
  }

  if (logFragment) {
    logElem.appendChild(logFragment);
    logFragment = null;
  }

  if (scrolledToEnd) {
      logElem.scrollTop = logElem.scrollHeight - logElem.clientHeight;
  }

  statusRefreshScheduled = false;
}
  1. 首先,在记录被滚动到底的时候 scrolledToEnd 会被设置为 true,否则被设置为 false。我们用它来确保给记录添加内容后,更新滚动位置使记录停留在末尾。
  2. 接下来,如果有任务进入队列中,我们更新进度和状态信息。
    1. 如果进度条当前的最大值(progressBarElem.max)不同于队列中当前的任务总数(totalTaskCount),我们就要更新任务总数(totalTaskCountElem)的显示内容和进度条的最大值(progressBarElem.max),以使它的比例正确。
    2. 我们对已运行的任务数做同样的操作;如果 progressBarElem.value 不同于当前正被处理的任务数(currentTaskNumber),我们就要更新当前运行的程序数量值(currentTaskNumberElem.textContent)和进度条当前值的显示(progressBarElem.value)。
  3. 然后,如果有文本等待被添加到记录中(logFragment 不为 null),我们使用 Element.appendChild() 将它添加到记录元素中,并将 logFragment 设置为 null 以避免重复操作。
  4. 如果我们操作开始的时候记录被滚动到末尾,我们要确保它一直处理末尾的位置。
  5. 然后我们将 statusRefreshScheduled 设置为 false,以表明我们已经处理过更新,可以安全地请求新的更新了。

向记录添加文本

function log(text) {
  if (!logFragment) {
      logFragment = document.createDocumentFragment();
  }

  let el = document.createElement("div");
  el.innerHTML = text;
  logFragment.appendChild(el);
}
  1. 首先如果当前不存在 logFragment 对象,则创建它。DocumentFragment 元素是一个伪 DOM,我们可以在其中插入元素,而无需立即更改主 DOM 本身。
  2. 接下来我们向 logFragment 中的伪 DOM 末尾添加一个新的 div 元素。
  3. logFragment 将会累积记录条目,直到下次因 DOM 改变而调用 updateDisplay() 的时候。

运行任务

现在,我们的任务管理和显示维护代码已经完成,可以开始设定完成工作的代码了。

任务处理器

logTaskHandler() 是我们用来作为任务处理器的函数,也是用作任务对象 handler 属性的值。它是一个简单的为每个任务向记录输出大量内容的函数,可以将此代码替换为希望在空闲时间执行的任何任务。只要记住任何 DOM 变化都需要通过 requestAnimationFrame() 处理。

function logTaskHandler(data) {
  log("<strong>Running task #" + currentTaskNumber + "</strong>");

  for (i=0; i<data.count; i+=1) {
    log((i+1).toString() + ". " + data.text);
  }
}

主程序

当用户点击“开始”按钮,会调用 decodeTechnoStuff() 函数,触发所有操作。

function decodeTechnoStuff() {
  totalTaskCount = 0;
  currentTaskNumber = 0;
  updateDisplay();

  let n = getRandomIntInclusive(100, 200);

  for (i=0; i<n; i++) {
    let taskData = {
      count: getRandomIntInclusive(75, 150),
      text: "This text is from task number " + (i+1).toString() + " of " + n
    };

    enqueueTask(logTaskHandler, taskData);
  }
}

document.getElementById("startButton").addEventListener("click", decodeTechnoStuff, false);
  1. decodeTechnoStuff() 开始执行时会将任务总数(到现在为止添加到队列中的任务数)清零,并随后调用 updateDisplay() 以重置显示为“没有任何事发生”的状态。
  2. getRandomIntInclusive() 方法创建一个随机数量(100200 之间)的任务。
  3. 随后我们开始一个循环以创建实际的任务。对于每个任务,我们创建一个对象 taskData,其中包含两个属性:
    • count 是从任务输出文本到记录中的次数。
    • text 是要输出到日志的文本。
  4. 我们调用 enqueueTask() 来将每个任务排入队列,将 logTaskHandler 传入作为处理函数;将 taskData 传入,待处理函数(logTaskHandler)调用时传入其中。

结果