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

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

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 デベロッパー ツールのパフォーマンス プロファイラで長時間実行されるタスク。タスクのブロック部分(50 ミリ秒超)は、赤い斜めのストライプのパターンで示されます。
Chrome のパフォーマンス プロファイラに表示される長いタスク。長いタスクは、タスクの隅に赤い三角形が表示され、タスクのブロック部分は斜めの赤いストライプのパターンで塗りつぶされます。

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

1 つの長いタスクと、同じタスクを短いタスクに分割した場合。長いタスクは 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 ミリ秒以上を費やす可能性があります。最悪の場合、特にリソースに制約のあるデバイスでは、これらのタスクの多くが大幅に長時間実行される可能性があります。

コードの実行を手動で遅らせる

デベロッパーがタスクを小さなタスクに分割するために使用している方法の 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() は、少なくともこの方法で使用する場合、この作業に適したツールではありません。

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 関数と同じですが、yield が含まれています。その結果、かつてモノリシックだったタスクが、関数ごとに 5 つの個別のタスクに分割されました。
saveSettings() 関数は、子関数を個別のタスクとして実行するようになりました。

専用のスケジューラ API

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

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

対応ブラウザ

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

ソース

スケジューラ API には、タスクのよりきめ細かいスケジューリングを可能にする postTask() 関数があります。これは、優先度の低いタスクがメインスレッドに譲歩するように、ブラウザが作業の優先順位付けを行うための 1 つの方法です。postTask() はプロミスを使用し、次の 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 つの図。割り込みなしで、長いタスクがあります。ゆずりの場合、タスクは短くなりますが、無関係なタスクによって中断される可能性があります。イージングと継続を使用すると、より短いタスクが増えますが、実行順序は維持されます。
scheduler.yield() を使用すると、タスクの実行は、イールドポイントの後でも中断したところから再開されます。

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

isInputPending() を使用しない

対応ブラウザ

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

ソース

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

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

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

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

まとめ

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

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

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

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

サムネイル画像は Amirali Mirhashemian 提供の Unsplash より取得。