【翻译】使用requestIdleCallback

@songhlc 2018-12-06 03:46:56发表于 yonyouyc/blog

原文链接

使用requestIdleCallback

作者:Paul Lewis

许许多多的网站和应用都有一大堆的script脚本需要执行。通常来说我们希望我们的javascript代码越早执行越好,而同时,我们又不希望我们的代码执行影响到用户的使用。当你在用户滚动页面的同时发送一段行为分析数据,或者当你需要将一些元素渲染到DOM之中时用户正在点击一个按钮,你的网站可能会在短时间内无法响应,而导致一些很糟糕的用户体验。

好消息是:现在已经提供了一种能帮助我们优化性能的API:requestIdleCallback。我们采用requestAnimationFrame的时候可以适当的调度我们的渲染效果,尽可能的让我的浏览器的帧数达到60fps的水平。与requestAnimationFrame不同的地方在于,requestIdleCallback仅会在浏览器执行线程有空闲的时候、或用户不进行IO操作的时候进行调度。这意味着你可以使你的代码在不阻塞用户使用的情况下执行。这个特性在chrome 47之后的版本中提供了支持。

为什么我们需要使用requestIdleCallback
自己规划什么时间点应该执行非关键性的任务/执行代码通常是一件比较困难的事情(没找到好的翻译方式)。我们无法精确的知道,每一帧渲染中还生效多少时间可执行。在requestAnimationFrame 回调执行之后,还需要有style calculations, layout, paint以及其他的一些浏览器内部处理需要执行。为了确保用户不处于交互中,通常需要需要监听所有的交互事件(scroll,touch,click)。即使你不需要他们的这些功能,但只有这样做你才能明确的知道用户是否正处于交互操作中。然而浏览器当然是知道每一帧渲染完后还有多少空闲时间,以及用户是否正在与你的网页进行交互。通过requestIdleCallback 我们得到了更高效的利用浏览器空闲时间的办法。

接着让我们看一看我们能利用它做到哪些事情

使用前检查

目前使用requestIdleCallback还有一点早,所以在使用前请先进行它是否能够被使用

if ('requestIdleCallback' in window) {
    // use requestIdleCallback to schedule work
} else {
    // do what you'd do
}

你也可以提供一段shim代码,来通过setTimeout来模拟

window.requestIdleCallback =
  window.requestIdleCallback ||
  function (cb) {
    var start = Date.now();
    return setTimeout(function () {
        cb({
            didTimeout: false,
            timeRemaining: function () {
                return Math.max(0, 50 - (Date.now() - start));
            }
        }); 
    }, 1);
}
window.cancelIdleCallback =
  window.cancelIdleCallback ||
  function (id) {
    clearTimeout(id);
  }

使用setTimeout并不完美,因为他并无法像requestIdleCallback一样准确了解浏览器空闲的时间。但至少你在window下我们的requestIdleCallback是可用的。

现在起,我们都假设requestIdleCallback是存在可用的

开始使用

调用requestIdleCallback和调用requestAnimationFrame很类似,将一个回调函数做为调用的第一个参数:

requestIdleCallback(myNonEssentialWork);

当myNonEssentialWork被调用的时候,会传递一个deadline对象做为回调函数的第一个参数,deadline里包含一个函数,告知你剩余的时间

function myNonEssentialWork (deadline) {
  while (deadline.timeRemaining() > 0) {
    doWorkIfNeeded();
  }
}

当timeRemaining函数返回0时,如果你还有其他事情要做,可以重新发起requestIdleCallback调用

function myNonEssentialWork (deadline) {
  while (deadline.timeRemaining() > 0 && tasks.length > 0)
    doWorkIfNeeded();
  if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

确保你的函数被调用

当所有线程都横忙碌的时候你会怎么做?你可能会担心你的回调函数会一直无法被执行。虽然requestIdleCallback和requestAnimationFrame很类似,但它额外提供了一个可选择的参数:timeout。当设置了timeout之后,回调函数会在超过时限后自动执行,而不用等待直到浏览器空闲。

requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });

当超时触发的时候你需要注意以下两件事情

  • deadline.timeRemaining() 会返回0
  • deadline.didTimeout 会返回true

所以你应该把你的代码按照如下方式改写

function myNonEssentialWork (deadline) {
  while ((deadline.timeRemaining() > 0 || deadline.didTimeout) &&
         tasks.length > 0)
    doWorkIfNeeded();
  if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

使用requestIdleCallback发送用户行为分析数据

在下面例子中,我们想要收集页面菜单中“--say--”的点击事件,由于菜单存在一些动画效果,我们不希望立即发送此事件到Google Analytics。我们会创建一个事件数组,并在后续的某个时间点,将事件一起发送出去。

var eventsToSend = [];
function onNavOpenClick () {
  // Animate the menu.
  menu.classList.add('open');
  // Store the event for later.
  eventsToSend.push(
    {
      category: 'button',
      action: 'click',
      label: 'nav',
      value: 'open'
    });
  schedulePendingEvents();
}

现在,我们使用requestIdleCallback来操作所有等待发送的事件。

function schedulePendingEvents() {
  // Only schedule the rIC if one has not already been set.
  if (isRequestIdleCallbackScheduled)
    return;
  isRequestIdleCallbackScheduled = true;
  if ('requestIdleCallback' in window) {
    requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
  } else {
    processPendingAnalyticsEvents();
  } 
}

此处我们设置的2s的超时时间,你可以根据你应用的实际情况进行灵活调整。对于用户行为分析数据这个场景,2s的超时时间会是一个相对比较合理的设置(和一直等到浏览器空闲再统一发送相比)。

function processPendingAnalyticsEvents (deadline) {
    // Reset the boolean so future rICs can be set.
    isRequestIdleCallbackScheduled = false;
    // If there is no deadline, just run as long as necessary.
    // This will be the case if requestIdleCallback doesn’t exist.
    if (typeof deadline === 'undefined')
        deadline = { timeRemaining: function () { return Number.MAX_VALUE } };
    // Go for as long as there is time remaining and work to do.
    while (deadline.timeRemaining() > 0 && eventsToSend.length > 0) {
      var evt = eventsToSend.pop();
      ga('send', 'event',
          evt.category,
          evt.action,
          evt.label,
          evt.value);
    }
    // Check if there are more events still to send.
    if (eventsToSend.length > 0)
        schedulePendingEvents();
}

上面这个例子中即使requestIdleCallback不存在也能够立即发送分析数据。在生产环境上,更好的方案是延迟发送分析数据,以免和用户的交互事件产生冲突而导致浏览器卡住。

使用requestIdleCallback变更DOM

另一种requestIdleCallback可以改善页面性能的场景是当你有一些非关键性的DOM需要更改的时候。比如:懒加载或者在列表滚动的时候在尾部不断追加内容。我们先来看一看requestIdleCallback如何融入到每一帧的渲染之中:

vsync---------------------------idel period

input -> rAF -> Frame commit -> [idle callback,idle callback]

有些情况下,浏览器每一帧都很忙,可能无暇顾及到任何callback。所以你不能指望每一帧的末尾都会有空闲时间来执行你想做的事情。注:这个和setImmediate不一样,setImmediate会在每一帧都执行(原文这么写的,后续针对setImmediate,我再研究一下发出来)

当callback在每一帧末尾回调时,这个回调会在上图流程中的Frame commit之后执行。这意味着页面的样式和布局会被重新计算,并重绘(如果需要)。如果我们在idle callback之中改变DOM结构,之前的布局重新计算将是无效的。假定在下一帧渲染的时候有任何影响布局的属性读取操作,比如:getBoundingClientRect,clientWidth等等,浏览器则需要做出一次Force Synchronous Layout 《关于Force Synchronous Layout》,需要翻墙,后续我会继续翻译这一篇
这会成为潜在的性能瓶颈

不要在idle callback中改变DOM的另一个原因是:改变DOM节点的时间消耗是不可预料的,通常和容易超过浏览器听的deadline。
最佳实践是只在requestAnimationFrame的callback中改变DOM。这意味着我们的代码需要使用document fragment,然后再下一个rAF 回调用添加到节点树之中。如果你在使用VDOM之列的库,你可以使用requestIdleCallback来改变页面结构,当DOM中真正生效则是在下一个rAF callback之中,而并非当前帧的idle callback。

我们来看看下面的代码

function processPendingElements (deadline) {
    // If there is no deadline, just run as long as necessary.
    if (typeof deadline === 'undefined')
        deadline = { timeRemaining: function () { return Number.MAX_VALUE } };
    if (!documentFragment)
        documentFragment = document.createDocumentFragment();
    // Go for as long as there is time remaining and work to do.
    while (deadline.timeRemaining() > 0 && elementsToAdd.length > 0) {
      // Create the element.
      var elToAdd = elementsToAdd.pop();
      var el = document.createElement(elToAdd.tag);
      el.textContent = elToAdd.content;
      // Add it to the fragment.
      documentFragment.appendChild(el);
      // Don't append to the document immediately, wait for the next
      // requestAnimationFrame callback.
      scheduleVisualUpdateIfNeeded();
    }
    // Check if there are more events still to send.
    if (elementsToAdd.length > 0)
        scheduleElementCreation();
}
function scheduleVisualUpdateIfNeeded() {
  if (isVisualUpdateScheduled)
    return;
  isVisualUpdateScheduled = true;
  requestAnimationFrame(appendDocumentFragment);
}
function appendDocumentFragment() {
  // Append the fragment and reset.
  document.body.appendChild(documentFragment);
  documentFragment = null;
}

我们来解释一下以上的代码:
代码里我创建了一个element,使用textContent的属性给element创建了内容。当创建了对象之后scheduleVisualUpdateIfNeeded函数被调用。scheduleVisualUpdateIfNeeded中会调用rAF来将实际的documentFragment追加到body之下。

经过这一系列操作之后,我们会发现追加DOM节点的操作变得更流畅了。