requestIdleCallback の使用

多くのサイトやアプリには、実行するスクリプトが多数あります。多くの場合、JavaScript はできる限り早く実行する必要がありますが、同時にユーザーの邪魔にならないようにする必要があります。ユーザーがページをスクロールしているときにアナリティクス データを送信したり、ユーザーがボタンをタップしているときに DOM に要素を追加したりすると、ウェブアプリが応答しなくなり、ユーザー エクスペリエンスが低下する可能性があります。

requestIdleCallback を使用して、重要でない処理をスケジュールする。

幸い、この問題を解決できる API(requestIdleCallback)が登場しました。requestAnimationFrame の導入によりアニメーションを適切にスケジュールし、60 fps を達成する可能性を最大化できたのと同じように、requestIdleCallback はフレームの終了時に空き時間があるときや、ユーザーがアクティブでないときに処理をスケジュールします。つまり、ユーザーの邪魔にならないように作業を行うことができます。この機能は Chrome 47 以降で利用可能ですので、Chrome Canary を使って今すぐお試しいただけます。これは試験運用版の機能であり、仕様は現在も変更されているため、今後変更される可能性があります。

requestIdleCallback を使用する理由

不要な処理を自分でスケジュールすることは非常に困難です。requestAnimationFrame コールバックの実行後には、スタイルの計算、レイアウト、ペイントなど、ブラウザ内部で実行する必要がある処理があるため、残りのフレーム時間が正確に把握できません。自作のソリューションでは、これらの要因をすべて考慮することはできません。ユーザーが何らかの方法で操作していないことを確認するには、機能に必要ない場合でも、すべての種類のインタラクション イベント(scrolltouchclick)にリスナーをアタッチする必要があります。これは、ユーザーが操作していないことを完全に確認するためです。一方、ブラウザはフレームの終了時に利用可能な時間と、ユーザーが操作しているかどうかを正確に把握しています。requestIdleCallback を使用すると、余分な時間を可能な限り効率的に利用できる API を取得できます。

詳しく見てみましょう。

requestIdleCallback の確認

requestIdleCallback はまだ初期段階であるため、使用前に利用可能であることを確認する必要があります。

if ('requestIdleCallback' in window) {
    // Use requestIdleCallback to schedule work.
} else {
    // Do what you’d do today.
}

動作をシムすることもできます。この場合、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);
    }

setTimeoutrequestIdleCallback のようにアイドル時間を知らないので、使用はおすすめしませんが、requestIdleCallback が使用できない場合は関数を直接呼び出すため、この方法でシミュレートしても問題ありません。シムを使用すると、requestIdleCallback が使用可能な場合は、呼び出しがサイレント リダイレクトされます。

ここでは、存在すると仮定しましょう。

requestIdleCallback を使用する

requestIdleCallback の呼び出しは、最初のパラメータとしてコールバック関数を取るという点で requestAnimationFrame と非常によく似ています。

requestIdleCallback(myNonEssentialWork);

myNonEssentialWork が呼び出されると、残り時間を示す数値を返す関数を含む deadline オブジェクトが渡されます。

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

timeRemaining 関数を呼び出して最新の値を取得できます。timeRemaining() がゼロを返した場合、まだ処理する必要がある場合は別の requestIdleCallback をスケジュールできます。

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

    if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

関数が呼び出されることを保証する

本当に忙しい場合はどうしますか?コールバックが呼び出されなくなるのではないかと心配されるかもしれません。requestIdleCallbackrequestAnimationFrame に似ていますが、2 番目のパラメータ(タイムアウト プロパティを含むオプション オブジェクト)をオプションで受け取るという点でも異なります。このタイムアウトが設定されている場合、ブラウザはコールバックを実行するまでの時間をミリ秒単位で指定します。

// Wait at most two seconds before processing events.
requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });

タイムアウトが発生したためにコールバックが実行された場合、次の 2 つのことがわかります。

  • timeRemaining() はゼロを返します。
  • deadline オブジェクトの didTimeout プロパティは true になります。

didTimeout が true の場合、ほとんどの場合、作業を実行して終了します。

function myNonEssentialWork (deadline) {

    // Use any remaining time, or, if timed out, just run through the tasks.
    while ((deadline.timeRemaining() > 0 || deadline.didTimeout) &&
            tasks.length > 0)
    doWorkIfNeeded();

    if (tasks.length > 0)
    requestIdleCallback(myNonEssentialWork);
}

このタイムアウトがユーザーに中断をもたらす可能性があるため(処理によってアプリが応答しなくなったり、動作がぎくしゃくしたりする可能性があるため)、このパラメータの設定には注意が必要です。可能であれば、コールバックを呼び出すタイミングをブラウザに任せます。

requestIdleCallback を使用してアナリティクス データを送信する

requestIdleCallback を使用してアナリティクス データを送信してみましょう。この場合、ナビゲーション メニューのタップなどのイベントをトラッキングする必要があります。ただし、通常は画面にアニメーションで表示されるため、このイベントを Google アナリティクスにすぐに送信することは避けるべきです。送信するイベントの配列を作成し、将来の時点で送信するようリクエストします。

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) {
    // Wait at most two seconds before processing events.
    requestIdleCallback(processPendingAnalyticsEvents, { timeout: 2000 });
    } else {
    processPendingAnalyticsEvents();
    }
}

ここではタイムアウトを 2 秒に設定していますが、この値はアプリケーションによって異なります。アナリティクス データの場合は、タイムアウトを使用して、将来のある時点でではなく、妥当な期間内にデータがレポートされるようにするのが理にかなっています。

最後に、requestIdleCallback が実行する関数を記述する必要があります。

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 がパフォーマンスに大きく貢献するもう 1 つの状況は、DOM の変更が必須ではない場合です。たとえば、増え続ける遅延読み込みリストの末尾にアイテムを追加する場合などです。requestIdleCallback が一般的なフレームにどのように収まるかを見てみましょう。

一般的なフレーム。

ブラウザが特定のフレームでコールバックを実行できないほど忙しい場合があるため、フレームの終了時に余裕のある時間が確保され、他の処理を行うことができるとは限りません。これは、フレームごとに実行される setImmediate とは異なります。

フレームの終了時にコールバックが発生すると、現在のフレームが commit された後に実行されるようにスケジュールされます。つまり、スタイルの変更が適用され、レイアウトが計算されます。アイドル状態のコールバック内で DOM を変更すると、そのレイアウト計算は無効になります。次のフレームに getBoundingClientRectclientWidth などのレイアウト読み取りがある場合、ブラウザは強制同期レイアウトを実行する必要があります。これはパフォーマンスのボトルネックになる可能性があります。

アイドル状態のコールバックで DOM の変更をトリガーしないもう 1 つの理由は、DOM の変更による時間的な影響が予測できないため、ブラウザが指定した期限を簡単に超過する可能性があることです。

requestAnimationFrame コールバック内でのみ DOM を変更することをおすすめします。このコールバックは、この種の処理を念頭に置いてブラウザによってスケジュールされるためです。つまり、コードでドキュメント フラグメントを使用し、次の requestAnimationFrame コールバックで追加する必要があります。VDOM ライブラリを使用している場合は、requestIdleCallback を使用して変更を行いますが、DOM パッチはアイドル コールバックではなく、次の requestAnimationFrame コールバックで適用します。

では、コードを見てみましょう。

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();
}

ここでは要素を作成して textContent プロパティを使用して要素にデータを入力していますが、要素の作成コードはもっと複雑になる可能性があります。要素の作成後に scheduleVisualUpdateIfNeeded が呼び出され、単一の requestAnimationFrame コールバックが設定されます。このコールバックは、ドキュメント フラグメントを本文に追加します。

function scheduleVisualUpdateIfNeeded() {

    if (isVisualUpdateScheduled)
    return;

    isVisualUpdateScheduled = true;

    requestAnimationFrame(appendDocumentFragment);
}

function appendDocumentFragment() {
    // Append the fragment and reset.
    document.body.appendChild(documentFragment);
    documentFragment = null;
}

問題がなければ、DOM にアイテムを追加する際のジャンクが大幅に軽減されます。非常にすばらしい仕上がりです。

よくある質問

  • ポリフィルはありますか?残念ながら、対応していません。ただし、setTimeout への透過的なリダイレクトを希望する場合は、シムがあります。この API が存在する理由は、ウェブ プラットフォームに非常に大きなギャップがあるためです。アクティビティがないことを推測するのは難しいですが、フレームの終了時に空き時間の長さを判断する JavaScript API はないため、せいぜい推測するしかありません。setTimeoutsetIntervalsetImmediate などの API を使用して作業をスケジュールできますが、requestIdleCallback のようにユーザー操作を回避するためにタイミングは設定されません。
  • 期限を過ぎるとどうなりますか?timeRemaining() が 0 を返しても、さらに長く実行する場合は、ブラウザが処理を停止する心配はありません。ただし、ブラウザではユーザーがスムーズに利用できるように期限が設定されているため、特別な理由がない限り、必ず期限を守ってください。
  • timeRemaining() が返す最大値はありますか?はい。現在は 50 ミリ秒です。応答性の高いアプリケーションを維持するには、ユーザー操作に対するすべてのレスポンスを 100 ミリ秒以内に抑える必要があります。ユーザーが操作した場合、ほとんどの場合、50 ミリ秒のウィンドウでアイドル状態のコールバックが完了し、ブラウザがユーザーの操作に応答できます。ブラウザが実行に十分な時間があると判断した場合、複数のアイドル状態のコールバックが連続してスケジュールされることがあります。
  • requestIdleCallback で実行すべきでない処理はありますか?理想的には、比較的予測可能な特性を持つ小さなチャンク(マイクロタスク)で作業する必要があります。たとえば、DOM を変更すると、スタイルの計算、レイアウト、ペイント、合成がトリガーされるため、実行時間が予測できなくなります。そのため、DOM の変更は、上記で説明したように requestAnimationFrame コールバックでのみ行う必要があります。もう 1 つ注意すべき点は、Promise の解決(または拒否)です。アイドル状態のコールバックが完了すると、残り時間がない場合でもコールバックがすぐに実行されます。
  • フレームの最後に常に requestIdleCallback が返されますか?いいえ、必ずしもそうとは限りません。ブラウザは、フレームの終了時やユーザーが操作していない期間に空き時間があるたびにコールバックをスケジュールします。コールバックがフレームごとに呼び出されることを求めてはいけません。また、特定の期間内に実行する必要がある場合は、タイムアウトを使用する必要があります。
  • requestIdleCallback コールバックを複数使用できますか?はい。複数の requestAnimationFrame コールバックを設定できる場合と同様に、複数のコールバックを設定できます。ただし、最初のコールバックでコールバック中に残りの時間がすべて使用された場合、他のコールバックに残りの時間が残っていないことに注意してください。他のコールバックは、ブラウザが次にアイドル状態になるまで待機してから実行する必要があります。実行する作業に応じて、1 つのアイドル状態のコールバックを設定して、その中で作業を分割することをおすすめします。または、タイムアウトを使用して、コールバックが時間切れにならないようにすることもできます。
  • 別のアイドル状態コールバック内に新しいアイドル状態コールバックを設定するとどうなりますか?新しいアイドル状態コールバックは、(現在のフレームではなく)次のフレームから、できるだけ早く実行されるようにスケジュールされます。

アイドル状態オン

requestIdleCallback は、ユーザーの邪魔にならないようにコードを実行できる優れた方法です。使いやすく、非常に柔軟性があります。まだ初期段階であり、仕様も完全に確定していないため、フィードバックをお待ちしております。

Chrome Canary でこの機能を試し、プロジェクトで使ってみて、フィードバックをお寄せください。