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

「メインスレッドをブロックしない」、「長いタスクを分割する」と言われていますが、それらの操作はどういう意味でしょうか。

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

JavaScript アプリを高速に保つための一般的なアドバイスは、次のアドバイスに集約される傾向があります。

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

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

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

タスクとは

タスクとは、ブラウザが行う個別の作業のことです。この処理には、レンダリング、HTML と CSS の解析、JavaScript の実行など、直接制御できない処理が含まれます。この中で、作成した JavaScript はおそらくタスクの最大のソースです。

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

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

  • ブラウザは、起動時に 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() は適切に設計されています。これらの関数のいずれかをデバッグする必要がある場合は、プロジェクト ツリーを走査して、各関数が何を行うかを把握できます。このように作業を分割することで、プロジェクトのナビゲーションとメンテナンスが容易になります。

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

Chrome のパフォーマンス プロファイラに表示される saveSettings 関数。最上位の関数は他の 5 つの関数を呼び出しますが、すべての処理が 1 つの長いタスクで行われるため、すべての処理が完了するまで、関数の実行結果はユーザーに表示されません。
5 つの関数を呼び出す 1 つの関数 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: 142.
  • 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 つのタスクに分割されました。その結果、タスク間でレイアウトとペイントを実行できるようになり、ポインタ操作が大幅に短縮され、ユーザーに迅速な視覚的レスポンスを提供できるようになります。

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

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

譲歩なし、譲歩あり、譲歩と継続ありのタスクを示す 3 つの図。譲歩しないと、長いタスクが発生します。譲渡を使用すると、タスクは短くなりますが、他の無関係なタスクによって中断される可能性があります。譲歩と継続を使用すると、タスクの数は増えますが、実行時間は短くなり、実行順序は維持されます。
scheduler.yield() を使用すると、他のタスクに移動する前に中断したところから再開されます。

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

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

解決策の一つは、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() をサポートしていないブラウザでは、優先的な継続は行われませんが、ブラウザの応答性を維持するために譲歩します。

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

これは、機能検出と、1 つのマイクロタスクを待機するフォールバックの両方を、便利な 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 は、ユーザーがページを操作しようとしたかどうかを確認し、入力が保留中の場合にのみ yield する方法を提供します。

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

しかし、この API のリリース以降、特に INP の導入により、譲歩に関する理解が深まりました。この API の使用はおすすめしません。代わりに、いくつかの理由から、入力が保留中かどうかにかかわらず、譲渡することをおすすめします。

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

まとめ

タスクの管理は困難ですが、管理することで、ユーザー操作に対するページの応答速度が向上します。タスクの管理と優先順位付けに関するアドバイスは 1 つだけではなく、さまざまな手法があります。繰り返しになりますが、タスクを管理する際に考慮すべき主な事項は次のとおりです。

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

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

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

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