New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【翻译】使用requestIdleCallback #61

Open
songhlc opened this Issue Dec 6, 2018 · 0 comments

Comments

Projects
None yet
1 participant
@songhlc
Member

songhlc commented Dec 6, 2018

原文链接

使用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节点的操作变得更流畅了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment