時間のかかるタスクを最適化する

「メインスレッドをブロックしない」や「長いタスクを分割する」とよく言われますが、これらのことを意味するのでしょうか。

JavaScript アプリの速度を維持するための一般的なアドバイスは、次のように要約されています。

  • 「メインスレッドをブロックしないでください。」
  • 「長いタスクを分割する」

これはすばらしいアドバイスですが、どのような作業が必要でしょうか。配布する JavaScript を少なくすることは良いことですが、それだけでユーザー インターフェースの応答性も向上するということですか?場合によっては、

JavaScript のタスクを最適化する方法を理解するには、まずタスクとブラウザがタスクを処理する仕組みを理解する必要があります。

タスクとは

タスクとは、ブラウザが行う個別の作業のことです。レンダリング、HTML と CSS の解析、JavaScript の実行など、直接制御できない作業も含まれます。これらのタスクの中で、最も多く発生するのは、作成する JavaScript です。

Chrome の DevTools のパフォーマンス プロファイラに表示されるタスクの可視化。タスクはスタックの一番上に配置され、クリック イベント ハンドラ、関数呼び出しなどのアイテムがその下に配置されます。このタスクには、右側のレンダリング作業も含まれます。
click イベント ハンドラによって開始されたタスク。Chrome DevTools のパフォーマンス プロファイラに表示されます。

JavaScript に関連するタスクは、次の 2 つの方法でパフォーマンスに影響します。

  • ブラウザが起動時に JavaScript ファイルをダウンロードすると、その JavaScript を解析してコンパイルするタスクがキューに追加され、後で実行できるようにします。
  • ページの実行中に、イベント ハンドラ、JavaScript 駆動型アニメーション、バックグラウンド アクティビティ(アナリティクス コレクションなど)を介したインタラクションの促進など、JavaScript が機能したときにタスクがキューに入れられることもあります。

これらの処理はすべて、Web Worker などの API を除き、メインスレッドで実行されます。

メインスレッドとは

メインスレッドは、ブラウザで実行されるほとんどのタスクと、記述したほとんどの JavaScript が実行されるスレッドです。

メインスレッドが処理できるのは、一度に 1 つのタスクだけです。50 ミリ秒を超えるタスクは、時間のかかるタスクとなります。50 ミリ秒を超えるタスクの場合、タスクの合計時間から 50 ミリ秒を差し引いたものが、タスクのブロック期間と呼ばれます。

ブラウザは、長さの長いタスクの実行中は操作をブロックしますが、タスクの実行時間が長すぎない限り、ユーザーは気付かないでしょう。ただし、長時間のタスクが多数あるときにユーザーがページを操作しようとすると、ユーザー インターフェースが応答しなくなり、メインスレッドが長時間ブロックされている場合は、破損する可能性もあります。

Chrome の DevTools のパフォーマンス プロファイラにおける長いタスク。タスクのブロック部分(50 ミリ秒超)は、赤い斜めのストライプのパターンで示されます。
Chrome のパフォーマンス プロファイラに表示される長いタスク。長いタスクは、タスクの隅に赤い三角形が表示され、タスクのブロック部分は斜めの赤いストライプのパターンで塗りつぶされます。

メインスレッドが長時間ブロックされないようにするには、長いタスクを複数の小さなタスクに分割します。

単一の長いタスク、または同じタスクを短いタスクに分割したもの。長いタスクは 1 つの大きな長方形ですが、チャンク化されたタスクは 5 つの小さなボックスで、全体の幅は長いタスクと同じです。
1 つの長いタスクと、同じタスクを 5 つの短いタスクに分割した場合の可視化。

これは、タスクが分割されると、ブラウザは優先度の高い作業(ユーザー操作など)に、より迅速に対応できるため重要です。その後、残りのタスクが完了まで実行され、最初にキューに入れた作業が確実に完了します。

タスクを分割することでユーザー操作を促進できる仕組みを示す図。上部では、長いタスクがタスクが完了するまでイベント ハンドラの実行をブロックしています。タスクの下部では、チャンクされたタスクにより、イベント ハンドラを本来よりも早く実行できるようになっています。
タスクが長すぎてブラウザが操作に十分に迅速に応答できない場合と、長いタスクを小さなタスクに分割した場合の、インタラクションへの影響の可視化。

上の図の上部では、ユーザー操作によってキューに追加されたイベント ハンドラが、長時間のタスクが完了するまで待機しなければならず、操作が遅延しています。このシナリオでは、ユーザーは遅延に気付いた可能性があります。下部では、イベント ハンドラの実行が早く開始されるため、操作が即時に行われたように感じることがあります。

タスクを分割することが重要な理由がわかったところで、JavaScript で分割する方法について学びましょう。

タスク管理戦略

ソフトウェア アーキテクチャにおける一般的なアドバイスは、作業を小さな機能に分割することです。

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

この例では、saveSettings() という関数があり、5 つの関数を呼び出して、フォームの検証、スピナーの表示、アプリケーション バックエンドへのデータ送信、ユーザー インターフェースの更新、アナリティクスの送信を行います。

概念的には、saveSettings() は適切に設計されています。これらの関数をデバッグする必要がある場合は、プロジェクト ツリーを走査して各関数の機能を把握できます。このような作業を分割することで、プロジェクトの操作と管理が容易になります。

ただし、ここで潜在的な問題となるのは、これらの関数は saveSettings() 関数内で実行されるため、JavaScript がこれらの関数を個別のタスクとして実行しないことです。つまり、5 つの関数はすべて 1 つのタスクとして実行されます。

Chrome のパフォーマンス プロファイラに表示される saveSettings 関数。トップレベルの関数が他の 5 つの関数を呼び出す間、すべての処理はメインスレッドをブロックする 1 つの長いタスクで実行されます。
5 つの関数を呼び出す単一の関数 saveSettings()。処理は、1 つの長いモノリシック タスクの一部として実行されます。

最良のシナリオでも、これらの関数の 1 つだけで、タスクの合計時間に 50 ミリ秒以上を費やす可能性があります。最悪の場合、特にリソースに制約のあるデバイスでは、これらのタスクの多くが大幅に長時間実行される可能性があります。

コードの実行を手動で延期する

デベロッパーがタスクを小さなものに分割するために使用した方法の一つに、setTimeout() があります。この手法では、関数を setTimeout() に渡します。これにより、0 のタイムアウトを指定しても、コールバックの実行が別のタスクに延期されます。

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

これは「収束」と呼ばれ、順次実行する必要がある一連の関数に最適です。

ただし、コードが常にこのように整理されているとは限りません。たとえば、ループで処理する必要がある大量のデータがあり、反復処理が多い場合、そのタスクは非常に長い時間がかかることがあります。

function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

ここで setTimeout() を使用することは、デベロッパーの作業効率の観点から問題があります。また、個々の反復処理がすべて迅速に実行されたとしても、データ配列全体の処理に非常に時間がかかる可能性があります。いずれにしても、setTimeout() はジョブに適したツールではありません。少なくとも、この方法では使えないからです。

async/await を使用して収益ポイントを作成する

優先度の低いタスクよりも重要なユーザー向けタスクを実行するには、タスクキューを短時間中断してブラウザに優先度の高いタスクを実行する機会を与えることで、メインスレッドに譲渡できます。

前述のように、setTimeout を使用してメインスレッドにゆずることができます。ただし、便宜上、読みやすくするために、Promise 内で setTimeout を呼び出し、その resolve メソッドをコールバックとして渡すこともできます。

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

yieldToMain() 関数の利点は、任意の async 関数で await できることです。前の例を基に、実行する関数の配列を作成し、各関数の実行後にメインスレッドにゆずることができます。

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

その結果、かつてモノリシックだったタスクが個別のタスクに分割されます。

Chrome のパフォーマンス プロファイラに示されたのと同じ saveSettings 関数(yielding のみ)。その結果、かつてモノリシックだったタスクが、関数ごとに 5 つの個別のタスクに分割されました。
saveSettings() 関数は、子関数を個別のタスクとして実行するようになりました。

専用のスケジューラ API

setTimeout はタスクを分割する効果的な方法ですが、欠点もあります。後続のタスクで実行するコードを延期してメインスレッドに委譲すると、そのタスクはキューの末尾に追加されます。

ページ上のすべてのコードを管理している場合は、タスクの優先順位を設定できる独自のスケジューラを作成できますが、サードパーティ スクリプトは独自のスケジューラを使用しません。事実上、そのような環境では作業に優先順位を付けることはできません。分割するか、ユーザー操作に明示的に譲歩するしかありません。

対応ブラウザ

  • Chrome: 94。
  • Edge: 94。
  • Firefox: フラグの背後。
  • Safari: サポートされていません。

ソース

スケジューラ API には、タスクのよりきめ細かいスケジューリングを可能にする postTask() 関数があります。これは、優先度の低いタスクがメインスレッドに譲歩するように、ブラウザが作業の優先順位付けを行うための 1 つの方法です。postTask() は Promise を使用し、次の 3 つの priority 設定のいずれかを受け入れます。

  • 'background': 優先度の最も低いタスク。
  • 'user-visible': 優先度が中程度のタスク。priority が設定されていない場合のデフォルトです。
  • 'user-blocking': 優先度高く実行する必要がある重要なタスク。

次のコードを例として考えてみましょう。ここでは、postTask() API を使用して 3 つのタスクを可能な限り高い優先度で実行し、残りの 2 つのタスクを可能な限り低い優先度で実行しています。

function saveSettings () {
  // Validate the form at high priority
  scheduler.postTask(validateForm, {priority: 'user-blocking'});

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, {priority: 'user-blocking'});

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, {priority: 'background'});

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, {priority: 'user-blocking'});

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, {priority: 'background'});
};

ここでは、ブラウザで優先されるタスク(ユーザー操作など)が必要に応じて間に割り込むことができるように、タスクの優先度がスケジュールされます。

Chrome のパフォーマンス プロファイラに示すように、saveSettings 機能は postTask を使用します。postTask は、saveSettings の各関数を分割して、ユーザー操作がブロックされずに実行できるように優先順位を付けます。
saveSettings() が実行されると、関数は postTask() を使用して個々の関数をスケジュールします。ユーザーに影響する重要な処理は優先度が高くスケジュールされ、ユーザーが認識していない処理はバックグラウンドで実行されるようにスケジュールされます。これにより、作業が適切に分割され、優先順位が付けられるため、ユーザー操作をより迅速に実行できます。

これは、postTask() の使用方法の簡単な例です。必要に応じて異なる TaskController インスタンスの優先度を変更する機能など、タスク間で優先度を共有可能な、さまざまな TaskController オブジェクトをインスタンス化できます。

scheduler.yield() API を使用した継続付き組み込みイールド

対応ブラウザ

  • Chrome: 129。
  • Edge: 129。
  • Firefox: サポートされていません。
  • Safari: サポートされていません。

ソース

scheduler.yield() は、ブラウザのメインスレッドに譲渡するように特別に設計された API です。使用方法は、このガイドの前半で説明した yieldToMain() 関数に似ています。

async function saveSettings () {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ]

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

このコードは大部分がよく似ていますが、yieldToMain() の代わりに await scheduler.yield() を使用しています。

ゆずりなし、ゆずりあり、ゆずりと継続ありのタスクを示す 3 つの図。割り込みなしで、長いタスクがあります。ゆずりの場合、タスクは短くなりますが、無関係なタスクによって中断される可能性があります。yielding と contitain を使用すると、より短いタスクが増えますが、その実行順序は維持されます。
scheduler.yield() を使用すると、タスクの実行は、明け渡しポイントの後でも中断したところから再開します。

scheduler.yield() のメリットは継続です。つまり、一連のタスクの途中で収益を出すと、他のスケジュールされたタスクは、その収益ポイント以降も同じ順序で続行されます。これにより、サードパーティ スクリプトのコードがコードの実行順序を中断するのを防ぐことができます。

isInputPending() を使用しない

対応ブラウザ

  • Chrome: 87。
  • Edge: 87。
  • Firefox: サポートされていません。
  • Safari: サポートされていません。

ソース

isInputPending() API を使用すると、ユーザーがページの操作を試みたかどうかを確認できます。入力が保留中の場合のみ、結果を返します。

これにより、入力が保留されていない場合は、JavaScript が続行され、タスクキューの後ろに移動することはありません。これにより、リリース予定で詳しく説明されているように、メインスレッドに返却されない可能性のあるサイトで、パフォーマンスが大幅に向上する可能性があります。

しかし、この API のリリース以降、特に INP の導入により、収益に関する Google の認識が高まっています。現在は、この API の使用はおすすめしません。代わりに、さまざまな理由から、入力が保留中かどうかに関係なく 明快にすることをおすすめします。

  • isInputPending() は、ユーザーが操作したにもかかわらず、false を誤って返す場合があります。
  • タスクが yield する必要があるのは入力の場合だけではありません。アニメーションやその他のユーザー インターフェースの定期的な更新は、レスポンシブなウェブページの提供と同様に重要です。
  • その後、scheduler.postTask()scheduler.yield() など、生成する懸念事項に対処する、より包括的な収益生成 API が導入されました。

まとめ

タスクの管理は難しいですが、タスクを管理することで、ページがユーザーの操作に迅速に応答できるようになります。タスクの管理と優先順位付けには、1 つの方法ではなく、さまざまな方法があります。繰り返しになりますが、タスクを管理する際に考慮すべき主な点は次のとおりです。

  • ユーザー向けの重要なタスクの場合は、メインスレッドに譲渡します。
  • postTask() を使用してタスクに優先順位を付けます。
  • scheduler.yield() をテストすることをご検討ください。
  • 最後に、関数内でできるだけ少ない処理を行う

これらのツールの 1 つ以上を使用すれば、ユーザーのニーズを優先しながら、重要性の低い作業は中断しないようにアプリケーションの作業を構造化できるはずです。これにより、ユーザー エクスペリエンスが向上し、応答性が向上し、使いやすくなります。

このガイドの技術的な検証に協力してくれた Philip Walton に感謝します。

サムネイル画像は Unsplash から提供。Amirali Mirhashemian 提供。