「メインスレッドをブロックしない」、「長いタスクを分割する」と言われても、実際にどうすればよいかわからないかもしれません。
公開日: 2022 年 9 月 30 日、最終更新日: 2024 年 12 月 19 日
JavaScript アプリを高速に保つための一般的なアドバイスは、次のようになります。
- 「メインスレッドをブロックしないでください。」
- 「時間のかかるタスクを分割しましょう。」
これは素晴らしいアドバイスですが、どのような作業が必要になるのでしょうか?JavaScript の量を減らすことは良いことですが、それだけでユーザー インターフェースの応答性が自動的に向上するのでしょうか?そうかもしれませんが、そうでないかもしれません。
JavaScript でタスクを最適化する方法を理解するには、まずタスクとは何か、ブラウザがタスクをどのように処理するのかを知る必要があります。
タスクとは
タスクとは、ブラウザが行う個別の作業のことです。この作業には、レンダリング、HTML と CSS の解析、JavaScript の実行など、直接制御できない作業が含まれます。この中で、おそらく最も多くのタスクを生み出すのは、ユーザーが記述する JavaScript でしょう。
click イベント ハンドラによって開始されたタスク。JavaScript に関連付けられたタスクは、次の 2 つの方法でパフォーマンスに影響します。
- ブラウザが起動時に JavaScript ファイルをダウンロードすると、その JavaScript を解析してコンパイルするタスクがキューに登録され、後で実行できるようになります。
- ページのライフサイクルの他のタイミングでは、JavaScript がイベント ハンドラによるインタラクションへの応答、JavaScript によるアニメーション、アナリティクス収集などのバックグラウンド アクティビティなどの処理を行うときに、タスクがキューに登録されます。
ウェブ ワーカーや類似の API を除く、これらの処理はすべてメインスレッドで行われます。
メインスレッドとは
メインスレッドは、ブラウザでほとんどのタスクが実行され、記述した JavaScript のほぼすべてが実行される場所です。
メインスレッドが一度に処理できるタスクは 1 つだけです。50 ミリ秒を超えるタスクはすべて長いタスクです。50 ミリ秒を超えるタスクの場合、タスクの合計時間から 50 ミリ秒を差し引いた時間が、タスクのブロック期間になります。
ブラウザは、タスクの実行中に操作が起こらないようにブロックしますが、タスクの実行時間が長すぎない限り、ユーザーはそれを認識しません。ただし、長いタスクが多数あるときにユーザーがページを操作しようとすると、メインスレッドが長時間ブロックされている場合は、ユーザー インターフェースが応答しなくなり、破損しているように見える可能性があります。
メインスレッドが長時間ブロックされないようにするには、長いタスクを複数の小さなタスクに分割します。
タスクを分割すると、ブラウザはユーザ操作などの優先度の高い作業にすばやく対応できるようになるため、この点が重要になります。その後、残りのタスクが実行されて完了し、最初にキューに登録した作業が確実に完了します。
上の図の上部では、ユーザー操作によってキューに登録されたイベント ハンドラが、開始する前に 1 つの長いタスクを待つ必要があり、操作の実行が遅れています。このシナリオでは、ユーザーが遅延に気付いている可能性があります。下部では、イベント ハンドラがより早く実行され、インタラクションが瞬時に感じられる可能性があります。
タスクを分割することが重要な理由を理解したところで、JavaScript でタスクを分割する方法を学びましょう。
タスク管理戦略
ソフトウェア アーキテクチャでよく言われるのは、作業を小さな関数に分割することです。
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
この例では、saveSettings() という名前の関数が 5 つの関数を呼び出して、フォームの検証、スピナーの表示、アプリケーション バックエンドへのデータの送信、ユーザー インターフェースの更新、分析情報の送信を行います。
概念的には、saveSettings() は適切に設計されています。これらの関数のいずれかをデバッグする必要がある場合は、プロジェクト ツリーをたどって、各関数の処理内容を確認できます。このように作業を分割すると、プロジェクトのナビゲーションとメンテナンスが容易になります。
ただし、これらの関数は saveSettings() 関数内で実行されるため、JavaScript はこれらの関数を個別のタスクとして実行しません。つまり、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()
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();
}
saveSettings() の実行が 2 つのタスクに分割されました。その結果、レイアウトとペイントがタスク間で実行されるため、ポインタ操作が大幅に短縮され、ユーザーはより迅速な視覚的レスポンスを得られます。ただし、他の yield アプローチに対する scheduler.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() をサポートしていないブラウザでは優先的な継続は行われませんが、ブラウザの応答性を維持するために引き続きイールドが行われます。
最後に、継続が優先されない場合(たとえば、既知のビジー状態のページで、イールドするとしばらくの間作業が完了しないリスクがある場合)、コードがメインスレッドにイールドできない場合があります。その場合、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 つの方法は、ジョブをバッチ処理し、前回のイールドから十分な時間が経過した場合にのみ、ジョブ間でイールドすることです。一般的な締め切りは 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 ミリ秒ごとにメインスレッドにのみ処理を譲ります。
isInputPending() を使用しない
isInputPending() API は、ユーザーがページを操作しようとしたかどうかを確認し、入力が保留中の場合にのみ結果を返す方法を提供します。
これにより、JavaScript は、入力が保留されていない場合に、タスクキューの末尾で中断して終了するのではなく、続行できます。これにより、Intent to Ship で詳しく説明されているように、メインスレッドに制御を戻さないサイトでパフォーマンスが大幅に向上します。
ただし、その API のリリース以降、特に INP の導入により、イールドの理解が深まりました。この API の使用はおすすめしません。代わりに、入力が保留中かどうかに関係なくイールドすることをおすすめします。これにはいくつかの理由があります。
isInputPending()は、ユーザーが操作を行ったにもかかわらず、状況によってはfalseを誤って返すことがあります。- タスクが yield する必要があるのは、入力の場合だけではありません。アニメーションやその他の通常のユーザー インターフェースの更新も、レスポンシブなウェブページを提供するために同様に重要です。
- その後、
scheduler.postTask()やscheduler.yield()など、イールドに関する懸念に対処する、より包括的なイールド API が導入されました。
まとめ
タスクの管理は難しいですが、タスクを管理することで、ページがユーザーの操作にすばやく応答できるようになります。タスクの管理と優先順位付けに関するアドバイスは 1 つだけではなく、さまざまな手法があります。タスクを管理する際に考慮すべき主な点は次のとおりです。
- 重要なユーザー向けタスクのためにメインスレッドに譲ります。
scheduler.yield()(クロスブラウザ フォールバック付き)を使用して、人間工学的に優先順位付けされた継続を生成して取得する- 最後に、関数で実行する処理をできるだけ少なくします。
scheduler.yield()、その明示的なタスク スケジューリング相対 scheduler.postTask()、タスクの優先度について詳しくは、優先度付きタスク スケジューリング API ドキュメントをご覧ください。
これらのツールを 1 つ以上使用することで、ユーザーのニーズを優先しつつ、重要度の低い作業も確実に実行されるように、アプリケーションの作業を構造化できます。これにより、応答性が高く、使いやすいユーザー エクスペリエンスが実現します。
このガイドの技術的な検証に協力してくれた Philip Walton に感謝します。
サムネイル画像は Unsplash から提供されました。Amirali Mirhashemian 氏に感謝いたします。