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

「メインスレッドをブロックしない」や「長いタスクを分割しない」と言われてきましたが、これはどういう意味でしょうか。

JavaScript アプリの速度を保つための一般的なアドバイスは、次のようにまとめられます。

  • 「メインスレッドをブロックしない。」
  • 「長いタスクを分けて。」

これはすばらしいアドバイスですが、どのような作業が必要になるでしょうか。配布する JavaScript を少なくすることは良いことですが、それだけでユーザー インターフェースの応答性も向上するということですか?そうかもしれませんが、そうでないかもしれません。

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

タスクとは

タスクとは、ブラウザが実行する個別の作業です。これには、レンダリング、HTML および CSS の解析、JavaScript の実行、その他ユーザーが直接制御できないような作業が含まれます。中でも、記述する JavaScript が、おそらくタスクの最大ソースとなります。

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

JavaScript に関連付けられたタスクは、いくつかの点でパフォーマンスに影響します。

  • ブラウザは起動時に JavaScript ファイルをダウンロードすると、その JavaScript を解析してコンパイルするタスクをキューに入れ、後で実行できるようにします。
  • ページの実行中に、イベント ハンドラ、JavaScript 駆動型アニメーション、バックグラウンド アクティビティ(アナリティクス コレクションなど)を介したインタラクションの促進など、JavaScript が機能したときにタスクがキューに入れられることもあります。

Web Worker や同様の API を除き、すべての処理はメインスレッドで行われます。

メインスレッドとは

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

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

ブラウザは、タスクの実行中に操作が発生するのをブロックします。ただし、タスクの実行時間が長すぎない限り、ユーザーはそのことを認識できません。しかし、長時間のタスクが多いときにユーザーがページを操作しようとすると、ユーザー インターフェースが応答していないと感じます。メインスレッドが長時間ブロックされると、ユーザー インターフェースが応答しなくなることもあります。

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

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

単一の長いタスク、または同じタスクを短いタスクに分割したもの。長いタスクは 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 つの関数を呼び出す単一の関数 saveSettings()。処理は、1 つの長いモノリシック タスクの一部として実行されます。

最良のシナリオでは、これらの関数のいずれか 1 つでも、タスクの合計時間に 50 ミリ秒以上寄与する可能性があります。最悪の場合、特にリソースの制約があるデバイスでは、このようなタスクの多くが長時間実行される可能性があります。

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

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

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

専用のスケジューラ API

setTimeout は、タスクを分割する効果的な方法ですが、後続のタスクで実行するコードを遅らせてメインスレッドに移行すると、そのタスクがキューの最後に追加されます。

ページ上のすべてのコードを管理している場合は、タスクに優先順位を付ける機能を備えた独自のスケジューラを作成できますが、サードパーティのスクリプトはスケジューラを使用しません。事実上、そのような環境では作業に優先順位を付けることはできません。データを分割するか、明示的にユーザーの操作に委ねることのみ可能です。

対応ブラウザ

  • 94
  • 94
  • x

ソース

スケジューラ API には、タスクのきめ細かなスケジューリングを可能にする postTask() 関数があります。ブラウザが作業に優先順位を付け、優先度の低いタスクがメインスレッドに送られるようにする方法の 1 つです。postTask() は Promise を使用し、次の 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 を使用した継続を使用した組み込みの収益

スケジューラ 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 つのタスクを示す 3 つの図。明け渡しなし、明け出しなし、調整あり、継続あり。生成しなければ、時間のかかるタスクが発生します。yielding を使用すると、時間は短くても、他の無関係なタスクによって中断される可能性のあるタスクが多くなります。収益と継続を使用すると、より短いタスクが増えますが、その実行順序は維持されます。
scheduler.yield() を使用すると、タスクの実行は、明け渡しポイントの後でも中断したところから再開します。

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

scheduler.postTask()priority: 'user-blocking' とともに使用すると、user-blocking の優先度が高いため継続する可能性が高くなります。当面は、このアプローチを代替として使用できます。

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

isInputPending() を使用しない

対応ブラウザ

  • 87
  • 87
  • x
  • x

isInputPending() API は、ユーザーがページを操作しようとしたかどうかを確認し、入力が保留中の場合にのみ離脱する手段を提供します。

これにより、保留中の入力がない場合に JavaScript が処理を続行できるようになり、処理が放棄されてタスクキューの最後方に送られることはなくなります。これにより、Intent to Ship で詳しく説明しているように、他の方法ではメインスレッドに戻れないサイトのパフォーマンスが大幅に向上します。

しかし、この API のリリース以降、特に INP の導入により、収益に関する Google の認識が高まっています。現在は、この API の使用はおすすめしません。代わりに、さまざまな理由から、入力が保留中かどうかに関係なく 明快にすることをおすすめします。

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

おわりに

タスクの管理は容易なことではありませんが、タスクの管理は容易なことから、ユーザーの操作に対してより迅速にページが反応するようになります。タスクの管理や優先順位付けに関する唯一のアドバイスはありませんが、むしろ、さまざまな手法があります。繰り返しになりますが、タスクを管理する際は、次の点を考慮する必要があります。

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

これらのツールの 1 つ以上を使用すれば、ユーザーのニーズを優先しつつ、重要性の低い作業は中断しないようにアプリケーションの作業を構造化できるはずです。これにより、ユーザー エクスペリエンスが向上し、応答性が向上し、使いやすくなります。

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

サムネイル画像は Unsplash から提供。Amirali Mirhashemian 提供。