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

JavaScript アプリを高速化するための一般的なアドバイスには、「メインスレッドをブロックしない」や「長いタスクを分割する」などがあります。このページでは、このアドバイスの意味と、JavaScript でタスクを最適化することが重要な理由について説明します。

タスクとは

タスクとは、ブラウザが実行する個々の作業のことです。これには、レンダリング、HTML と CSS の解析、作成した JavaScript コードの実行など、ユーザーが直接制御できない処理が含まれます。ページの JavaScript は ブラウザタスクの主要なソースです

Chrome の DevTools のパフォーマンス プロファイルのタスクのスクリーンショット。タスクはスタックの一番上にあり、クリック イベント ハンドラ、関数呼び出し、さらにその下にアイテムがあります。タスクの右側には、レンダリング処理も含まれます。
Chrome DevTools のパフォーマンス プロファイラに表示される、click イベント ハンドラによって開始されたタスク。

タスクはいくつかの方法でパフォーマンスに影響します。たとえば、ブラウザが起動時に JavaScript ファイルをダウンロードすると、その JavaScript を実行できるように、その JavaScript を解析してコンパイルするタスクをキューに入れます。ページのライフサイクルの後半では、JavaScript が機能したときに、イベント ハンドラによるインタラクションの促進、JavaScript ドリブン アニメーション、アナリティクス コレクションなどのバックグラウンド アクティビティなどのタスクが開始されます。ウェブワーカーや同様の API を除き、これらはすべてメインスレッドで実行されます。

メインスレッドは何か?

メインスレッドでは、ほとんどのタスクがブラウザで実行されます。また、ここで作成するほぼすべての JavaScript が実行されます。

メインスレッドは一度に 1 つのタスクしか処理できません。50 ミリ秒を超えるタスクは「長いタスク」としてカウントされます。長時間のタスクやレンダリングの更新中にユーザーがページを操作しようとすると、ブラウザはその操作を処理するまで待機する必要があり、レイテンシが発生します。

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

これを防ぐには、長いタスクを複数の小さなタスクに分割し、それぞれの実行時間を短縮します。これは、長いタスクを分割と呼ばれます。

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

タスクを分割することで、ブラウザが他のタスク間のユーザー操作など、優先度の高い作業に応答する機会を増やすことができます。これにより、インタラクションがはるかに速くなります。ブラウザが長いタスクの終了を待つ間に遅延に気づくかもしれません。

タスクを分割すると、ユーザーの操作が容易になります。上部の長いタスクは、タスクが終了するまでイベント ハンドラの実行をブロックします。一番下にあるチャンクタスクにより、イベント ハンドラの実行時間が本来よりも早くなります。
タスクが長すぎると、ブラウザが操作にすばやく応答できなくなります。 タスクを分割することで、それらのインタラクションをより迅速に行えるようになります。

タスク管理の戦略

JavaScript は、タスク実行の実行から完了モデルを使用するため、各関数を単一のタスクとして扱います。つまり、次の例のように、他の複数の関数を呼び出す関数は、呼び出された関数がすべて完了するまで実行しなければならず、それによってブラウザの速度が低下します。

function saveSettings () { //This is a long task.
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}
Chrome のパフォーマンス プロファイラに表示された saveSettings 関数。トップレベルの関数は他の 5 つの関数を呼び出しますが、すべての処理は、メインスレッドをブロックする 1 つの長いタスクで行われます。
5 つの関数を呼び出す単一の関数 saveSettings()。この作業は、1 つの長いモノリシック タスクの一部として実行されます。

コードに複数のメソッドを呼び出す関数が含まれている場合は、複数の関数に分割します。これにより、ブラウザが操作に応答する機会が増えるだけでなく、コードの読み取り、保守、テストの作成が容易になります。以降のセクションでは、長い関数を分割し、関数を構成するタスクに優先順位を付けるための戦略について説明します。

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

一部のタスクの実行を延期するには、関連する関数を 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);
}

これは、順番に実行する必要がある一連の関数に最適です。コードの構成が異なる場合は、別のアプローチが必要になります。次の例は、ループを使用して大量のデータを処理する関数です。データセットが大きければ大きいほど、この処理には時間がかかります。また、必ずしもループ内で setTimeout() を配置するのに適した場所がなくなります。

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

幸いなことに、コードの実行を後のタスクで遅らせることができる API が他にもあります。postMessage() を使用してタイムアウトを短縮することをおすすめします。

requestIdleCallback() を使用して作業を分割することもできますが、タスクは最も優先度の低い、ブラウザのアイドル時間中にのみスケジュールされます。つまり、メインスレッドが特にビジー状態の場合、requestIdleCallback() でスケジュールされたタスクは実行されない可能性があります。

async/await を使用して収益点を作成する

優先度の低いタスクの前にユーザー向けの重要なタスクを確実に実行するには、タスクキューを短時間中断してメインスレッドに譲り渡し、ブラウザがより重要なタスクを実行する機会をブラウザに提供します。

最もわかりやすい方法は、setTimeout() の呼び出しで解決される Promise を使用する方法です。

function yieldToMain () {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

saveSettings() 関数では、各関数呼び出しの後に yieldToMain() 関数を 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();
  }
}

重要なポイント: すべての関数呼び出しの後で放棄する必要はありません。たとえば、ユーザー インターフェースの重要な更新につながる 2 つの関数を実行する場合、その間に間に合わないことをおすすめします。可能であれば、まずその処理を実行させ、その後、バックグラウンド処理や、ユーザーに表示されない重要度の低い処理を行う関数間で分離することを検討してください。

Chrome のパフォーマンス プロファイラで、同じ saveSettings 関数に yield が追加されました。タスクは、関数ごとに 1 つずつ、計 5 つのタスクに分かれています。
saveSettings() 関数は、子関数を個別のタスクとして実行するようになりました。

専用のスケジューラ API

これまでに説明した API はタスクの分割に役立ちますが、大きな欠点があります。コードを遅延して後のタスクで実行すると、そのコードがタスクキューの最後に追加されます。

ページ上のすべてのコードを管理する場合は、独自のスケジューラを作成してタスクに優先順位を付けることができます。ただし、サードパーティ スクリプトはスケジューラを使用しないため、その場合は処理に優先順位を付けることはできません。データの分割やユーザーの操作にあてはめることしかできません。

対応ブラウザ

  • 94
  • 94
  • x

ソース

スケジューラ API には postTask() 関数が用意されています。これにより、タスクのスケジューリングを細かく行うことができます。また、優先順位の低いタスクがメインスレッドに譲れるようにブラウザが作業に優先順位を付ける際にも役立ちます。postTask() は Promise を使用し、priority 設定を受け入れます。

postTask() API には次の 3 つの優先度があります。

  • '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() を使用して個々の関数呼び出しをスケジュールします。ユーザー向けの重要な処理は高い優先度でスケジュールされ、ユーザーが認識していない作業はバックグラウンドで実行するようにスケジュールされます。これにより、処理が適切に分割され、優先順位が設定されるため、ユーザー操作をより迅速に実行できます。

また、必要に応じて異なる TaskController インスタンスの優先度を変更する機能など、タスク間で優先度を共有する異なる TaskController オブジェクトをインスタンス化することもできます。

まもなく提供される scheduler.yield() API を使用した、継続を伴う組み込み yield

重要なポイント: scheduler.yield() の詳細については、オリジン トライアル(終了時)と説明をご覧ください。

スケジューラ API への追加として提案されている 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() のメリットは継続です。つまり、一連のタスクの途中で放棄した場合、他のスケジュール タスクは、その明け渡し点より後に同じ順序で続行されます。これにより、サードパーティのスクリプトがコードの実行順序を制御できなくなります。

また、priority: 'user-blocking'scheduler.postTask() を使用すると、user-blocking の優先度が高いため、処理が続行される可能性が高くなります。scheduler.yield() がより広く利用できるようになるまで、代替手段として使用できます。

setTimeout()(または scheduler.postTask()priority: 'user-visible' を指定するか、または明示的な priority を指定しない)を使用すると、キューの最後にタスクがスケジュールされ、他の保留中のタスクが続行前に実行されます。

isInputPending() での入力でイールド

対応ブラウザ

  • 87
  • 87
  • x
  • x

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

これにより、JavaScript は保留中の入力がないときでも、タスクキューの後ろに残って終了するのではなく、処理を続行できます。その結果、リリースの意図で説明されているように、メインスレッドに譲れないサイトのパフォーマンスが大幅に向上します。

ただし、この API のリリース以降、特に INP の導入以降、生成に関する Google の認識は向上しました。この API の使用はおすすめしません。代わりに、入力が保留中かどうかに関係なく 結果を出力することをおすすめします。最適化案が変わった理由はいくつかあります。

  • ユーザーが操作を行った際に、API が誤って false を返すことがあります。
  • タスクで成果が得られるケースは、入力だけではありません。アニメーションやその他の定期的なユーザー インターフェースの更新は、レスポンシブ ウェブページを提供するうえで同様に重要です。
  • その後、yield に関する問題に対処するために、scheduler.postTask()scheduler.yield() などのより包括的な yying API が導入されました。

おわりに

タスクの管理は簡単ではありませんが、タスクの管理をすることで、ユーザーの操作に迅速に対応できるようになります。タスクの管理と優先順位付けには、ユースケースに応じてさまざまな方法があります。繰り返しになりますが、タスクを管理するときに考慮すべき主な事項は次のとおりです。

  • ユーザー向けの重要なタスクはメインスレッドに任せる。
  • scheduler.yield() を試すことを検討してください。
  • postTask() でタスクに優先順位を付けます。
  • 最後に、関数で行う処理はできるだけ少なくします

これらのツールのうち 1 つ以上を使用することで、ユーザーのニーズを優先しながら、重要度の低い作業が引き続き行われるようにアプリの作業を構造化できます。これにより、応答性が向上し、より楽しめるようになるため、ユーザー エクスペリエンスが向上します。

このドキュメントの技術的調査に協力してくれた Philip Walton に感謝します。

サムネイル画像(出典: UnsplashAmirali Mirhashemian 提供)