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

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

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

JavaScript アプリの高速化に関する一般的なアドバイスは、次のアドバイスに集約されます。

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

これは良いアドバイスですが、どのような作業が必要になるのでしょうか?JavaScript を減らすことは良いことですが、レスポンシブなユーザー インターフェースに自動的につながるのでしょうか?場合によっては、

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

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

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

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

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

これらの処理はすべて、ウェブワーカーなどの API を除き、メインスレッドで実行されます。

メインスレッドとは

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

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

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

Chrome デベロッパー ツールのパフォーマンス プロファイラで長時間実行されるタスク。タスクのブロック部分(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);
}

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

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

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

ここで setTimeout() を使用することは、デベロッパーの作業効率の観点から問題があります。また、ネストされた setTimeout() が 5 回繰り返されると、追加の setTimeout() ごとにブラウザは最小 5 ミリ秒の遅延を適用するようになります。

setTimeout には、譲渡に関して別の欠点もあります。setTimeout を使用して後続のタスクで実行するコードを延期してメインスレッドに譲渡すると、そのタスクはキューの末尾に追加されます。他のタスクが待機している場合は、デフレート コードの前に実行されます。

専用の yielding API: scheduler.yield()

Browser Support

  • Chrome: 129.
  • Edge: 129.
  • Firefox: not 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 つのタスクに分割されました。その結果、レイアウトとペイントをタスク間で実行できるため、ポインタ操作が大幅に短縮され、ユーザーに迅速な視覚的なレスポンスを提供できます。

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

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

ゆずりなし、ゆずりあり、ゆずりと継続ありのタスクを示す 3 つの図。ゆずり合いがないと、長いタスクが発生します。ゆずりの場合、タスクは短くなりますが、無関係なタスクによって中断される可能性があります。イージングと継続を使用すると、より短いタスクが増えますが、実行順序は維持されます。
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() をサポートしていないブラウザは優先された継続を取得しませんが、ブラウザの応答性を維持するために引き続き譲渡します。

最後に、継続が優先されない場合、コードがメインスレッドに譲渡できない場合があります(たとえば、ビジー状態が知られているページで、譲渡するとしばらく作業が完了しないリスクがある場合など)。その場合、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 できることです。

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

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

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

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

まとめ

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

  • ユーザー向けの重要なタスクの場合は、メインスレッドに譲渡します。
  • scheduler.yield()(クロスブラウザ フォールバックあり)を使用して、人間工学に基づいて優先順位付けされた継続を取得する
  • 最後に、関数内でできるだけ少ない処理を行う

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

これらのツールの 1 つ以上を使用すると、ユーザーのニーズを優先しながら、それほど重要でない作業も確実に行うように、アプリケーション内の作業を構造化できます。これにより、レスポンスが速く、使いやすいユーザー エクスペリエンスが実現します。

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

サムネイル画像は UnsplashAmirali Mirhashemian 提供。