ボタンの処理が終わっても画面がすぐ変わらなかったり、Promise.then()がsetTimeout()より先に実行されたりするのは、ブラウザーがJavaScriptと描画を調整しているためです。イベントループにはタスクキューと別のマイクロタスクキューがあります。単一のFIFOキューだと考えると、実行順序や描画時期を誤解します。

1. JavaScript実行とブラウザーのイベントループ

JavaScriptエージェントは現在実行中の仕事を処理し、ブラウザーは入力、タイマー、ネットワーク完了など後で行う処理を調整します。HTML Standardはイベントループ、1つ以上のタスクキュー、別個のマイクロタスクキュー、描画手順を区別します。ブラウザーのスケジューラーが常に単純な1本のキューであるという意味ではありません。タスクの種類によってキューや選択規則が異なります。

同期JavaScriptが実行中は、その処理が終わるまでブラウザーのメインスレッド上の他の作業は進みにくくなります。長い計算やループは入力処理と描画を遅らせる可能性があります。非同期APIは現在の呼び出しスタックが空になった後の処理を予約しますが、別スレッドで実行されることや、画面がすぐ描画されることを意味しません。

2. タスクとマイクロタスクの違い

タイマーやイベントのコールバックは多くの場合タスクとして実行されます。Promiseの反応処理やqueueMicrotask()で登録した処理はマイクロタスクです。正確なタスクの配置はAPIの仕様によるため、すべての非同期コールバックが一つの順序規則に従うとは限りません。現在の処理で登録されたPromise反応は、後続のタイマータスクより先に処理されるのが一般的ですが、setTimeout(callback, 0)は即時実行の時刻を保証しません。

HTMLイベントループは定義された時点でマイクロタスクチェックポイントを実行します。キューを処理中に新しいマイクロタスクが追加されると、そのチェックポイントで続けて処理されることがあります。マイクロタスクを追加し続けると、次のタスクや描画機会が遅れる可能性があります。大きな処理をqueueMicrotask()で細切れにしても、その間に必ず描画されるわけではありません。

3. Promiseの完了は描画通知ではない

Promiseがsettleすると反応処理がマイクロタスクとして登録されます。現在の同期処理が終わった後、チェックポイントでその処理が進みます。ただし、コールバック直後に画面が描画される保証はありません。描画はイベントループの別の処理であり、ユーザーエージェントは文書の状態と描画機会を考慮します。すべてのタスクの直後に必ず画面を描くわけではありません。

現行HTML Standardの描画手順では、描画機会がある場合、requestAnimationFrame()のコールバックを実行してからスタイルとレイアウトを更新し、画面へ反映します。ブラウザーは描画更新を省略できるため、このコールバックは同期的な再描画や即時描画の命令ではありません。ここでは仕様の手順順序を簡略化して説明しており、すべてのタスクの後にフレームが描画されるという意味ではありません。

表示フレームに合わせた変更にはrequestAnimationFrame()が適する場合があります。入力に応答できるよう長い計算を譲らせるには、タスク間に分割したり適切なWorkerを検討したりします。目的に応じて選んでください。Promiseは非同期結果をつなぐ仕組みで、フレームのために実行を譲るスケジューラーではありません。

4. 実務家の視点:順序と応答性を分けて調べる

不具合を調べるときは同期コードの終了時点と、登録したタスク・マイクロタスクを記録します。Performance traceで長いメインスレッド処理と描画を一緒に確認すると、「コールバックは動いたが画面にはまだ出ない」状態を切り分けやすくなります。0ミリ秒タイマーを正確な時計として扱わず、マイクロタスクが一度だけ処理されるとも仮定しないでください。

長い同期計算をPromiseで包むだけでは応答性は改善しません。作業を減らすか分割し、描画フレームに合わせる視覚更新には対応APIを選びます。タスクソースやスケジューリングの細部は規格と実装に依存するため、重要な挙動は対象ブラウザーで測定してください。

参考資料