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.
}

その動作を 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 のようにアイドル時間を認識しないためおすすめできませんが、requestIdleCallback が使用できない場合は関数を直接呼び出すため、この方法でシミングするメリットはありません。shim が機能していれば、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 番目のパラメータ(timeout プロパティを持つオプション オブジェクト)を受け取る点も異なります。このタイムアウトを設定すると、ブラウザにコールバックの実行時間(ミリ秒単位)が与えられます。

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

典型的なフレーム。

ブラウザがビジー状態になり、特定のフレーム内でコールバックを実行できない場合もあるため、フレームの終了時に他の処理を行うための空き時間がまったくないとは想定しないでください。そのため、フレームごとに実行される setImmediate などとは異なります。

フレームの最後でコールバックが呼び出された場合、現在のフレームが commit された後に呼び出されるようにスケジュールされます。つまり、スタイル変更が適用され、重要な点として、レイアウトが計算されます。アイドル コールバック内で DOM の変更を行うと、そのレイアウト計算は無効になります。次のフレームでなんらかのレイアウト読み取りがある場合は、getBoundingClientRectclientWidth などの場合、ブラウザで強制同期レイアウトを実行しなければならず、これがパフォーマンスのボトルネックになる可能性があります。

アイドル状態のコールバックで DOM の変更がトリガーされないもう 1 つの理由は、DOM の変更による時間への影響は予測不可能であり、ブラウザが提供する期限を簡単に過ぎてしまうためです。

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 に透過的にリダイレクトしたい場合は、shim があります。この API が存在する理由は、この API がウェブ プラットフォームに非常に現実的なギャップを埋めることができるからです。アクティビティがないことを推測するのは困難ですが、フレームの終了時点での空き容量を判断するための JavaScript API が存在しないため、せいぜい推測する必要があります。setTimeoutsetIntervalsetImmediate などの API を使用して作業のスケジュールを設定できますが、requestIdleCallback のようなユーザー操作を避けるためのタイミングは設定されていません。
  • 期限を超過した場合はどうなりますか? timeRemaining() から 0 が返されなくても、もっと長時間実行するよう選択した場合は、ブラウザで処理が停止する心配をせずに実行できます。ただし、ブラウザにはユーザーに円滑なエクスペリエンスを提供するための期限が与えられるため、正当な理由がない限り、必ず期限を守ってください。
  • timeRemaining() が返す最大値はありますか? はい、現在は 50 ミリ秒です。応答性の高いアプリケーションを維持するには、ユーザー操作に対するすべてのレスポンスを 100 ミリ秒未満に維持する必要があります。ユーザーが 50 ミリ秒のウィンドウを操作すると、ほとんどの場合はアイドル コールバックが完了し、ブラウザがユーザーの操作に応答できる必要があります。アイドル状態のコールバックが連続して複数回スケジュールされることがあります(ブラウザが、それらのコールバックを実行するのに十分な時間があると判断した場合)。
  • requestIdleCallback に行ってはいけない処理はありますか? 理想的には、仕事は比較的予測可能な特性を持つ小さなまとまり(マイクロタスク)で行われるべきです。たとえば、特に DOM を変更すると、スタイル計算、レイアウト、描画、合成がトリガーされるため、実行時間が予測不能になります。そのため、上記のように DOM の変更は requestAnimationFrame コールバック内でのみ行うようにしてください。もう一つの注意すべき点は、Promise を解決(または拒否)することです。残りの時間がなくなったとしても、アイドル状態のコールバックが終了するとすぐにコールバックが実行されるためです。
  • フレームの最後に必ず requestIdleCallback を取得しますか? いいえ、常にそうとは限りません。ブラウザは、フレームの終了時に空き時間がある場合、またはユーザーが操作していない時間帯にコールバックのスケジュールを設定します。コールバックがフレームごとに呼び出されるとは限りません。また、特定の期間内にコールバックを実行する必要がある場合は、タイムアウトを利用する必要があります。
  • 複数の requestIdleCallback コールバックを使用できますか? はい。複数の requestAnimationFrame コールバックを使用できるのとほぼ同じです。ただし、最初のコールバックがコールバック中の残り時間を使い切った場合、他のコールバックの時間がなくなることを覚えておいてください。他のコールバックは、ブラウザが次にアイドル状態になるまで待ってから実行する必要があります。行おうとしている作業によっては、アイドル状態のコールバックを 1 つにして、そこで作業を分割した方がよい場合があります。または、タイムアウトを使用して、コールバックで時間が不足しないようにすることもできます。
  • 別のコールバックの内部に新しいアイドル コールバックを設定するとどうなりますか? 新しいアイドル コールバックは、(現在のフレームではなく)次のフレームを起点として可能な限り早く実行されるようスケジュール設定されます。

アイドル状態です!

requestIdleCallback は、ユーザーの操作を妨げることなく、コードを確実に実行できる優れた方法です。使いやすく、柔軟性に優れています。とはいえ、開発はまだ初期段階であり、仕様は完全には定着していません。そのため、皆様からのフィードバックをお待ちしております。

Chrome Canary をお試しいただき、ご自身のプロジェクトに一新して、成果をお聞かせください。