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

「メインスレッドをブロックしない」、「長いタスクを分割する」と言われても、実際にどうすればよいかわからないことがあります。

公開日: 2022 年 9 月 30 日、最終更新日: 2024 年 12 月 19 日

JavaScript アプリを高速に保つための一般的なアドバイスは、次のようになります。

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

これは素晴らしいアドバイスですが、どのような作業が必要になるのでしょうか?JavaScript の量を減らすことは良いことですが、それだけでユーザー インターフェースの応答性が自動的に向上するわけではありません。そうかもしれませんが、そうでないかもしれません。

JavaScript でタスクを最適化する方法を理解するには、まずタスクとは何か、ブラウザがタスクをどのように処理するのかを知る必要があります。

タスクとは

タスクとは、ブラウザが行う個別の作業のことです。この作業には、レンダリング、HTML と CSS の解析、JavaScript の実行など、直接制御できない作業が含まれます。このうち、記述する JavaScript がタスクの最大のソースになる可能性があります。

Chrome の DevTools のパフォーマンス プロファイラに表示されるタスクの可視化。タスクはスタックの最上部にあり、クリック イベント ハンドラ、関数呼び出し、その他の項目がその下にあります。このタスクには、右側のレンダリング作業も含まれています。
Chrome DevTools のパフォーマンス プロファイラに表示される、click イベント ハンドラによって開始されたタスク。

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

  • ブラウザが起動時に JavaScript ファイルをダウンロードすると、その JavaScript を解析してコンパイルするタスクがキューに登録され、後で実行できるようになります。
  • ページのライフサイクルの他のタイミングでは、JavaScript がイベント ハンドラによるインタラクションへの応答、JavaScript によるアニメーション、アナリティクス収集などのバックグラウンド アクティビティなどの処理を行うときに、タスクがキューに登録されます。

ウェブ ワーカーや類似の API を除く、これらの処理はすべてメインスレッドで行われます。

メインスレッドとは

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

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

ブラウザは、タスクの実行中に操作が起こらないようにブロックしますが、タスクの実行時間が長すぎない限り、ユーザーはそれを認識しません。ただし、長いタスクが多数あるときにユーザーがページを操作しようとすると、メインスレッドが長時間ブロックされるため、ユーザー インターフェースが応答しなくなり、場合によっては壊れたように見えることがあります。

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

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

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

タスクを分割すると、ブラウザはユーザー操作などの優先度の高い作業にずっと早く対応できるようになるため、この点は重要です。その後、残りのタスクが実行されて完了し、最初にキューに登録した作業が完了します。

タスクを分割することでユーザーの操作が容易になる様子を示す図。上部では、長いタスクが完了するまで、イベント ハンドラが実行されないようにブロックしています。下部のチャンク化されたタスクでは、イベント ハンドラを通常よりも早く実行できます。
タスクが長すぎてブラウザがインタラクションに十分な速さで応答できない場合と、長いタスクが小さなタスクに分割された場合で、インタラクションにどのような影響があるかを可視化したものです。

上の図の上部では、ユーザー操作によってキューに登録されたイベント ハンドラが、開始する前に 1 つの長いタスクを待つ必要があり、操作の実行が遅延しています。このシナリオでは、ユーザーが遅延に気付いた可能性があります。下部では、イベント ハンドラがより早く実行され、インタラクションが瞬時に感じられる可能性があります。

タスクを分割することが重要な理由を理解したところで、JavaScript でタスクを分割する方法を学びましょう。

タスク管理戦略

ソフトウェア アーキテクチャでよく言われるのは、作業を小さな関数に分割することです。

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

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

概念的には、saveSettings() は適切に設計されています。これらの関数のいずれかをデバッグする必要がある場合は、プロジェクト ツリーをたどって、各関数の処理内容を確認できます。このように作業を分割すると、プロジェクトのナビゲーションとメンテナンスが容易になります。

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

Chrome のパフォーマンス プロファイラに表示された saveSettings 関数。トップレベル関数は他の 5 つの関数を呼び出しますが、すべての処理は 1 つの長いタスクで行われるため、関数を実行した結果がユーザーに表示されるのは、すべての処理が完了してからになります。
5 つの関数を呼び出す単一の関数 saveSettings()。この処理は 1 つの長いモノリシック タスクの一部として実行され、5 つの関数がすべて完了するまで視覚的なレスポンスがブロックされます。

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

この場合、saveSettings() はユーザーのクリックによってトリガーされます。ブラウザは関数全体の実行が完了するまでレスポンスを表示できないため、この長時間タスクの結果は UI の遅延と無応答となり、Interaction to Next Paint(INP)のスコアが低くなります。

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

優先度の低いタスクよりも先に、重要なユーザー向けタスクと UI レスポンスが実行されるようにするには、作業を一時的に中断してブラウザに重要なタスクを実行する機会を与えることで、メインスレッドに譲ることができます。

デベロッパーがタスクをより小さなタスクに分割するために使用してきた方法の 1 つに、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() を 5 回ネストすると、ブラウザは追加の setTimeout() ごとに 5 ミリ秒の最小遅延を課すようになります

setTimeout には、イールドに関するもう 1 つの欠点もあります。setTimeout を使用して後続のタスクで実行するコードを遅延させることでメインスレッドにイールドすると、そのタスクはキューの末尾に追加されます。他に待機中のタスクがある場合は、遅延コードの前に実行されます。

専用のイールド API: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox Technology Preview: supported.
  • Safari: not supported.

Source

scheduler.yield() は、ブラウザのメインスレッドに処理を譲るために特別に設計された API です。

言語レベルの構文や特別なコンストラクトではなく、scheduler.yield() は将来のタスクで解決される Promise を返す関数にすぎません。その Promise が解決された後に実行されるようにチェーンされたコード(明示的な .then() チェーン内、または非同期関数で await した後)は、その後のタスクで実行されます。

実際には、await scheduler.yield() を挿入すると、関数はその時点で実行を一時停止し、メインスレッドに制御を渡します。関数の残りの部分(関数の継続)の実行は、新しいイベントループ タスクで実行されるようにスケジュール設定されます。そのタスクが開始されると、待機中の Promise が解決され、関数は中断した箇所から実行を続行します。

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

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
Chrome のパフォーマンス プロファイラに示されている saveSettings 関数が、2 つのタスクに分割された。最初のタスクは 2 つの関数を呼び出して yield し、レイアウトとペイントの処理が行われてユーザーに可視のレスポンスが返されるようにします。その結果、クリック イベントは 64 ミリ秒で完了します。2 つ目のタスクは、最後の 3 つの関数を呼び出します。
関数 saveSettings() の実行が 2 つのタスクに分割されました。その結果、レイアウトとペイントがタスク間で実行されるため、ポインタ操作が大幅に短縮され、ユーザーはより迅速な視覚的レスポンスを得られます。

scheduler.yield() の他の yield アプローチに対する真のメリットは、継続が優先されることです。つまり、タスクの途中で yield すると、他の同様のタスクが開始される前に、現在のタスクの継続が実行されます。

これにより、他のタスクソース(サードパーティのスクリプトのタスクなど)のコードがコードの実行順序を中断するのを防ぐことができます。

yield なし、yield あり、yield と継続ありのタスクを示す 3 つの図。yield を使用しないと、長いタスクが発生します。イールドを使用すると、タスクの数は増えますが、タスクの長さは短くなります。ただし、関連性のない他のタスクによって中断される可能性があります。yield と継続を使用すると、タスクの数は増えますが、実行順序は維持されます。
scheduler.yield() を使用すると、継続は他のタスクに進む前に中断したところから再開します。

クロスブラウザのサポート

scheduler.yield() はまだすべてのブラウザでサポートされていないため、フォールバックが必要です。

1 つの解決策は、ビルドに scheduler-polyfill をドロップすることです。そうすれば、scheduler.yield() を直接使用できます。ポリフィルは他のタスク スケジューリング関数へのフォールバックを処理するため、ブラウザ間で同様に動作します。

また、scheduler.yield() が使用できない場合のフォールバックとして、Promise でラップされた setTimeout のみを使用して、数行でよりシンプルなバージョンを記述することもできます。

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

scheduler.yield() をサポートしていないブラウザでは優先的な継続は行われませんが、ブラウザの応答性を維持するために引き続きイールドが行われます。

最後に、継続が優先されない場合(たとえば、既知のビジー状態のページで、yield するとしばらくの間処理が完了しないリスクがある場合など)、コードがメインスレッドに yield できないことがあります。その場合、scheduler.yield() はプログレッシブ エンハンスメントの一種として扱われる可能性があります。つまり、scheduler.yield() が利用可能なブラウザでは yield し、それ以外の場合は続行します。

これは、機能検出と単一のマイクロタスクの待機へのフォールバックを 1 行の便利なコードで行うことができます。

// Yield to the main thread if scheduler.yield() is available.
await globalThis.scheduler?.yield?.();

scheduler.yield() を使用して長時間実行される処理を分割する

これらの scheduler.yield() の使用方法のメリットは、任意の async 関数で await できることです。

たとえば、実行するジョブの配列があり、それらが長いタスクになることが多い場合は、yield を挿入してタスクを分割できます。

async function runJobs(jobQueue) {
  for (const job of jobQueue) {
    // Run the job:
    job();

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

runJobs() の継続が優先されますが、ユーザー入力への視覚的な応答などの優先度の高い処理も実行できます。ジョブの長いリストが完了するまで待つ必要はありません。

ただし、これは効率的な yield の使用方法ではありません。scheduler.yield() は高速で効率的ですが、オーバーヘッドがあります。jobQueue のジョブの一部が非常に短い場合、オーバーヘッドがすぐに増加し、実際の作業の実行よりも、譲渡と再開に費やされる時間が長くなる可能性があります。

1 つの方法は、ジョブをバッチ処理し、前回の yield から十分な時間が経過した場合にのみ、ジョブ間で yield することです。一般的な締め切りは 50 ミリ秒で、タスクが長いタスクにならないようにしますが、応答性とジョブキューの完了時間のトレードオフとして調整できます。

async function runJobs(jobQueue, deadline=50) {
  let lastYield = performance.now();

  for (const job of jobQueue) {
    // Run the job:
    job();

    // If it's been longer than the deadline, yield to the main thread:
    if (performance.now() - lastYield > deadline) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

その結果、ジョブは実行に時間がかかりすぎないように分割されますが、ランナーは 50 ミリ秒ごとにメインスレッドにのみ制御を渡します。

Chrome DevTools のパフォーマンス パネルに表示された一連のジョブ関数。実行が複数のタスクに分割されている
ジョブが複数のタスクにバッチ処理されます。

isInputPending() を使用しない

Browser Support

  • Chrome: 87.
  • Edge: 87.
  • Firefox: not supported.
  • Safari: not supported.

Source

isInputPending() API は、ユーザーがページを操作しようとしたかどうかを確認し、入力が保留中の場合にのみ結果を返す方法を提供します。

これにより、JavaScript は、入力が保留されていない場合に、タスクキューの末尾で中断して終了するのではなく、続行できます。これにより、Intent to Ship で詳しく説明されているように、メインスレッドに制御を戻さないサイトでパフォーマンスが大幅に向上します。

ただし、その API のリリース以降、特に INP の導入により、イールドの理解が深まりました。この API の使用はおすすめしません。代わりに、入力が保留中かどうかに関係なく、次の理由からイールドすることをおすすめします。

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

まとめ

タスクの管理は難しいですが、タスクを管理することで、ページがユーザーの操作にすばやく応答できるようになります。タスクの管理と優先順位付けには、単一のアドバイスではなく、さまざまな手法があります。タスクを管理する際に考慮すべき主な点は次のとおりです。

  • 重要なユーザー向けタスクのためにメインスレッドに譲ります。
  • scheduler.yield()(クロスブラウザ フォールバック付き)を使用して、人間工学的に優先度の高い継続を生成して取得する
  • 最後に、関数で実行する処理をできるだけ少なくします。

scheduler.yield()、その明示的なタスク スケジューリング相対 scheduler.postTask()、タスクの優先度について詳しくは、優先度付きタスク スケジューリング API ドキュメントをご覧ください。

これらのツールを 1 つ以上使用することで、ユーザーのニーズを優先しつつ、重要度の低い作業も確実に実行されるように、アプリケーションの作業を構造化できます。これにより、より応答性が高く、より楽しく使用できるユーザー エクスペリエンスが実現します。

このガイドの技術的な検証にご協力いただいた Philip Walton 氏に感謝いたします。

サムネイル画像は Unsplash から提供されました。Amirali Mirhashemian 氏に感謝いたします。