Node.jsと同様に、ブラウザのJavaScript実行フローは、イベントループに基づいています。
イベントループの仕組みを理解することは、最適化にとって、そして時には正しいアーキテクチャにとって重要です。
この章では、まず物事の仕組みについての理論的な詳細を説明し、次にその知識の実用的応用を見ていきます。
イベントループ
イベントループの概念は非常にシンプルです。無限ループがあり、JavaScriptエンジンはタスクを待ち、実行してからスリープ状態になり、さらにタスクを待ちます。
エンジンの一般的なアルゴリズム
- タスクがある間
- 最も古いタスクから順番に実行します。
- タスクが表示されるまでスリープし、次に1に進みます。
これは、ページを閲覧するときに目にすることの形式化です。 JavaScriptエンジンはほとんどの時間何もせず、スクリプト/ハンドラ/イベントがアクティブになった場合にのみ実行されます。
タスクの例
- 外部スクリプト
<script src="...">
がロードされると、タスクはそれを実行することです。 - ユーザーがマウスを動かすと、タスクは
mousemove
イベントをディスパッチし、ハンドラを実行することです。 - スケジュールされた
setTimeout
の時間が来ると、タスクはそのコールバックを実行することです。 - …など。
タスクが設定され、エンジンがそれらを処理し、次に(スリープしてCPUをほとんど消費せずに)さらにタスクを待ちます。
エンジンがビジー状態のときにタスクが来る場合があり、その場合はキューに入れられます。
タスクはキュー、いわゆる「マクロタスクキュー」(v8用語)を形成します
たとえば、エンジンがscript
の実行でビジー状態の間に、ユーザーがマウスを動かしてmousemove
が発生したり、setTimeout
の期限が来たりするなど、これらのタスクは上の図に示すようにキューを形成します。
キューからのタスクは、「先入れ先出し」方式で処理されます。 エンジンブラウザがscript
の処理を完了すると、mousemove
イベント、次にsetTimeout
ハンドラなどを処理します。
これまでのところ、非常にシンプルですよね?
さらに2つの詳細
- エンジンがタスクを実行している間は、レンダリングは決して行われません。 タスクに時間がかかっても問題ありません。 DOMへの変更は、タスクが完了した後にのみ描画されます。
- タスクに時間がかかりすぎると、ブラウザはユーザーイベントの処理など、他のタスクを実行できません。そのため、しばらくすると、「ページが応答しません」のような警告が表示され、ページ全体でタスクを強制終了することを提案します。これは、複雑な計算が多すぎる場合、またはプログラミングエラーによって無限ループが発生した場合に発生します。
それは理論でした。それでは、その知識をどのように適用できるかを見てみましょう。
ユースケース1:CPU負荷の高いタスクの分割
CPU負荷の高いタスクがあるとしましょう。
たとえば、構文の強調表示(このページのコード例に色を付けるために使用されます)は、CPUにかなりの負荷がかかります。 コードを強調表示するために、分析を実行し、多くの色付き要素を作成し、それらをドキュメントに追加します。これは、大量のテキストでは多くの時間がかかります。
エンジンが構文の強調表示でビジー状態のときは、他のDOM関連の処理、ユーザーイベントの処理などを行うことができません。ブラウザが少し「しゃっくり」したり、「ハング」したりする可能性があり、これは許容できません。
大きなタスクを複数の部分に分割することで、問題を回避できます。 最初の100行を強調表示し、次の100行にsetTimeout
(遅延ゼロ)をスケジュールする、などです。
このアプローチを実証するために、簡単にするために、テキストの強調表示の代わりに、1
から1000000000
までカウントする関数を使用してみましょう。
以下のコードを実行すると、エンジンはしばらくの間「ハング」します。 サーバーサイドJSの場合はそれがはっきりとわかり、ブラウザで実行している場合は、ページ上の他のボタンをクリックしてみてください。カウントが完了するまで他のイベントが処理されないことがわかります。
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("Done in " + (Date.now() - start) + 'ms');
}
count();
ブラウザは「スクリプトの実行に時間がかかりすぎています」という警告を表示する場合さえあります。
入れ子になったsetTimeout
呼び出しを使用してジョブを分割してみましょう
let i = 0;
let start = Date.now();
function count() {
// do a piece of the heavy job (*)
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // schedule the new call (**)
}
}
count();
これで、「カウント」プロセス中にブラウザインターフェースは完全に機能します。
count
の1回の実行は、ジョブの一部(*)
を実行し、必要に応じて自身を再スケジュールします(**)
- 最初の実行カウント:
i=1...1000000
。 - 2回目実行カウント:
i=1000001..2000000
。 - …など。
エンジンがパート1の実行でビジー状態のときに新しいサイドタスク(たとえば、onclick
イベント)が発生した場合、それはキューに入れられ、パート1が完了した後に、次のパートの前に実行されます。 count
実行の間に定期的にイベントループに戻ることで、JavaScriptエンジンが他のことを行ったり、他のユーザーアクションに反応したりするのに十分な「空気」が提供されます。
注目すべき点は、setTimeout
でジョブを分割する場合と分割しない場合の両方のバリアントの速度が同等であるということです。 全体的なカウント時間に大きな違いはありません。
それらを近づけるために、改善を行いましょう。
スケジューリングをcount()
の先頭に移動します
let i = 0;
let start = Date.now();
function count() {
// move the scheduling to the beginning
if (i < 1e9 - 1e6) {
setTimeout(count); // schedule the new call
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("Done in " + (Date.now() - start) + 'ms');
}
}
count();
これで、count()
を開始し、さらにcount()
する必要があることがわかった場合は、ジョブを実行する前にすぐにスケジュールします。
実行すると、かなり時間が短縮されていることが easilyわかります。
なぜでしょうか?
それは簡単です。ご存じのとおり、ブラウザ内では、多数の入れ子になったsetTimeout
呼び出しに対して4ミリ秒の最小遅延があります。 0
を設定しても、4ms
(またはそれ以上)になります。 したがって、早くスケジュールするほど、実行速度が速くなります。
最終的に、CPU負荷の高いタスクを複数のパートに分割しました。これで、ユーザーインターフェースがブロックされることはありません。 また、全体的な実行時間はそれほど長くはありません。
ユースケース2:進捗表示
ブラウザスクリプトの負荷の高いタスクを分割することのもう1つの利点は、進捗状況を表示できることです。
前述のように、DOMへの変更は、現在実行中のタスクが完了した後にのみ描画されます。タスクの実行時間がどれくらい長くても関係ありません。
一方では、それは素晴らしいことです。なぜなら、私たちの関数は多くの要素を作成し、それらを1つずつドキュメントに追加し、スタイルを変更する可能性があるからです。訪問者は「中間」の未完成の状態を見ることはありません。 重要なことですよね?
デモは次のとおりです。i
への変更は関数が終了するまで表示されないため、最後の値のみが表示されます
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
…しかし、タスク中に何かを表示したい場合もあります。たとえば、プログレスバーです。
setTimeout
を使用して負荷の高いタスクを複数に分割すると、変更はその間に描画されます。
これはよりきれいに見えます
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
これで、<div>
にはi
の増加値が表示されます。一種のプログレスバーです。
ユースケース3:イベント後に何かを実行する
イベントハンドラでは、イベントがバブルアップしてすべてのレベルで処理されるまで、一部のアクションを延期することを決定する場合があります。 これは、コードを遅延ゼロのsetTimeout
でラップすることで実行できます。
カスタムイベントのディスパッチの章では、例を見ました。カスタムイベントmenu-open
はsetTimeout
でディスパッチされるため、「click」イベントが完全に処理された後に発生します。
menu.onclick = function() {
// ...
// create a custom event with the clicked menu item data
let customEvent = new CustomEvent("menu-open", {
bubbles: true
});
// dispatch the custom event asynchronously
setTimeout(() => menu.dispatchEvent(customEvent));
};
マクロタスクとマイクロタスク
この章で説明したマクロタスクに加えて、マイクロタスクの章で説明したマイクロタスクがあります。
マイクロタスクは、もっぱら私たちのコードから発生します。 通常、これらはpromiseによって作成されます。.then/catch/finally
ハンドラの実行はマイクロタスクになります。 マイクロタスクは、promise処理の別の形式であるため、await
の「内部」でも使用されます。
また、マイクロタスクキューで実行するためにfunc
をキューに入れる特別な関数queueMicrotask(func)
もあります。
すべてのマクロタスクの直後に、エンジンは他のマクロタスクやレンダリングなどを実行する前に、マイクロタスクキューからすべてのタスクを実行します。
たとえば、見てみましょう
setTimeout(() => alert("timeout"));
Promise.resolve()
.then(() => alert("promise"));
alert("code");
ここでどのような順序になるでしょうか?
code
が最初に表示されます。これは通常の同期呼び出しであるためです。promise
が2番目に表示されます。.then
はマイクロタスクキューを通過し、現在のコードの後に実行されるためです。timeout
は最後に表示されます。これはマクロタスクであるためです。
よりリッチなイベントループの図は次のようになります(順序は上から下、つまり、最初にスクリプト、次にマイクロタスク、レンダリングなどです)
すべてのマイクロタスクは、他のイベント処理やレンダリング、または他のマクロタスクが実行される前に完了します。
これは重要です。マイクロタスク間でアプリケーション環境が基本的に同じであること(マウス座標の変更なし、新しいネットワークデータなしなど)を保証するためです。
現在のコードの後で、しかし変更がレンダリングされたり新しいイベントが処理されたりする前に、関数を非同期的に実行したい場合は、queueMicrotask
でスケジュールできます。
前に示したものと同様の「カウントプログレスバー」の例を次に示しますが、setTimeout
の代わりにqueueMicrotask
が使用されています。 レンダリングが最後に行われることがわかります。同期コードとまったく同じです
<div id="progress"></div>
<script>
let i = 0;
function count() {
// do a piece of the heavy job (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e6) {
queueMicrotask(count);
}
}
count();
</script>
まとめ
より詳細なイベントループアルゴリズム(ただし、仕様と比較して簡略化されています)
- マクロタスクキュー(例:「script」)から最も古いタスクをデキューして実行します。
- すべてのマイクロタスクを実行します
- マイクロタスクキューが空でない間
- 最も古いマイクロタスクをデキューして実行します。
- マイクロタスクキューが空でない間
- 変更があればレンダリングします。
- マクロタスクキューが空の場合は、マクロタスクが表示されるまで待ちます。
- ステップ1に進みます。
新しいマクロタスクをスケジュールするには
- 遅延ゼロの
setTimeout(f)
を使用します。
大きな計算負荷の高いタスクをブラウザがユーザーイベントに反応し、その間の進捗を表示できるようにするために、小さな断片に分割するために使用できます。
また、イベントハンドラでイベントが完全に処理された(バブリングが完了した)後にアクションをスケジュールするためにも使用されます。
新しいマイクロタスクをスケジュールするには
queueMicrotask(f)
を使用します。- また、Promiseハンドラはマイクロタスクキューを通過します。
マイクロタスク間にはUIやネットワークイベントの処理は行われません。マイクロタスクは次々にすぐに実行されます。
そのため、関数を非同期的に実行したいが、環境の状態は維持したい場合は、queueMicrotask
を使用することがあります。
イベントループをブロックすべきでない、長く重い計算には、Web Workersを使用できます。
これは、別の並列スレッドでコードを実行する方法です。
Web Workersはメインプロセスとメッセージを交換できますが、独自の変数と独自のイベントループを持ちます。
Web WorkersはDOMにアクセスできないため、主に計算用、複数のCPUコアを同時に使用する場合に役立ちます。
コメント
<code>
タグを使用し、複数行の場合は<pre>
タグで囲み、10行を超える場合はサンドボックス(plnkr、jsbin、codepen…)を使用してください。