ブラウザの心臓部をハックする:イベントループとレンダリングの深淵
こんにちは。日々、プロファイル結果の炎上と戦い、V8やBlinkの機嫌を取りながら生きているフロントエンド・チーフアーキテクトの私だ。
モダンなWebアプリケーションのパフォーマンスチューニングにおいて、私たちは「なぜかカクつく」「インタラクションがもたつく」という謎の現象に幾度となく直面してきたはずだ。JavaScriptのコードは綺麗に書いた、不要な再レンダリングも減らした、それなのに60fps(あるいは120fps)の壁をなぜ超えられないのか?
その答えの多くは、「JavaScriptのタスクキューと、ブラウザのレンダリングエンジンが回すイベントループが、どのように裏で密結合しているか」を理解しているかどうか、に尽きる。
今回は、教科書的な「非同期処理の基本」を優しくおさらいする気はない。BlinkやWebKitといった実世界のレンダリングエンジンが、イベントループの各フェーズで何を考え、どうやってDOM/CSSOMの変更をピクセルに変換しているのか。その泥臭くて美しい内部構造の深淵へ、あなたを連れて行こう。
—
1. イベントループとレンダリングの「同期点」を暴く
JavaScriptはシングルスレッドである。これは常識だ。しかし、Webブラウザという巨大なC++製アプリケーションの文脈において、JavaScriptエンジン(V8など)は単独で動いているわけではない。レイアウト計算、ペイント、コンポジットを司るレンダリングスレッドと緊密に協調動作している。
ここで多くのエンジニアが誤解しているのが、「JavaScriptの実行が完全に終わってから画面が描画される」という雑な理解だ。現実はもっとシビアで、洗練されている。
イベントループの一連のサイクル(Tick)は、大まかに以下のステップで進む。
1. タスクの実行(Task / MacroTask): マクロタスクキュー(`setTimeout`、I/O、DOMイベントなど)から1つ取り出して実行する。
2. マイクロタスクの消化(Microtask Queue): ここが重要。 タスクの実行が完了した直後、JavaScriptエンジンは「スタックが空になった瞬間」にマイクロタスクキュー(`Promise.then`、`MutationObserver`など)を完全に空になるまでループして処理し続ける。
3. レンダリング更新フェーズ(Rendering Update): ブラウザが「今、画面を更新すべきタイミングか?」を判断し、必要であれば以下のサブステップを走らせる。
- `requestAnimationFrame` (rAF) コールバックの実行
- スタイルの再計算 (Recalculate Style)
- レイアウト / リフロー (Layout)
- ペイント / レイヤー化 (Paint / Composite)
悪夢のマイクロタスク・ループ(Infinite Microtask starvation)
もし、あなたがマイクロタスクの中でさらにマイクロタスクを生成し続けたらどうなるか? 想像通り、ブラウザは永遠にステップ2から抜け出せなくなる。
// 【危険】絶対に真似してはいけないアンチパターン
function triggerInfiniteStarvation() {
Promise.resolve().then(() => {
// 永遠にマイクロタスクを生成し続ける
triggerInfiniteStarvation();
});
}
// これを実行した瞬間、ブラウザのレンダリングフェーズは一生訪れず、
// タブは完全にフリーズ(無応答)する。UIスレッドの完全な死だ。
ステップ2のマイクロタスクキューの処理は、「現在のタスクの直後、かつレンダリングの直前」という特等席にいる。しかし、ここに重すぎる処理や無限ループを仕込むと、レンダリングフェーズが完全にブロックされ、ユーザーの入力(clickやscroll)に対する反応が完全に消失する。これが「フレーム落ち」や「入力遅延」の根本的な原因の一つだ。
—
2. レンダリング更新フェーズの内部構造と `requestAnimationFrame` の真実
ブラウザは通常、ディスプレイのリフレッシュレート(60Hzなら約16.67ms毎、120Hzなら約8.33ms毎)に同期して画面を更新しようとする。
ここで、多くの開発者が勘違いしているのが `requestAnimationFrame` (rAF) のタイミングだ。rAFは、レンダリング更新フェーズの最初に実行される。
[マクロタスク] -> [マイクロタスク全消化] -> [rAF実行] -> [スタイル計算 & レイアウト] -> [ペイント]
したがって、もしrAFのコールバックの中で、DOMのサイズを強引に読み取り(`element.getBoundingClientRect()` など)、その直後にDOMを書き換えるようなコードを書くと何が起きるか?
// リフローの嵐を引き起こす最悪のパターン
requestAnimationFrame(() => {
// 1. DOMの読み取り(ここで前フレームのレイアウト情報が強制フラッシュされる)
const currentWidth = box.getBoundingClientRect().width;
// 2. DOMの書き込み(レイアウトの無効化 = Forced Synchronous Layout)
box.style.width = `${currentWidth + 10}px`;
});
このコードは、ブラウザがこれから行おうとしている「レイアウト計算」の直前に、JavaScript側で強制的にレイアウト情報を要求し(強制同期レイアウト/レイアウトスラッシング)、さらにその場でレイアウトを破壊する変更を加えている。
結果として、ブラウザは同じフレーム内で何度もレイアウトを再計算せざるを得なくなり、GPUやCPUのパイプラインを盛大に焼き尽くすことになる。
堅牢なアーキテクチャのための「読み書きの分離」
この惨劇を防ぐための黄金律は、「DOMの読み取りと書き込みを完全に分離し、バッチ処理する」ことだ。
// 【推奨】読み取りと書き込みを分離した効率的な実装
let nextWidth = null;
// 1. まず現在の状態をまとめて「読み取る」
requestAnimationFrame(() => {
const currentWidth = box.getBoundingClientRect().width;
nextWidth = currentWidth + 10;
// 2. 読み取りが終わったら、次のフレーム、あるいはマイクロタスクでまとめて「書き込む」
requestAnimationFrame(() => {
box.style.width = `${nextWidth}px`;
});
});
このようにイベントループとレンダリングのパイプラインを意識するだけで、無駄なレイアウトスラッシングを劇的に削減できる。
—
3. 非同期の競合を防ぐ:スケジューリングの最適化と `scheduler.postTask()`
現代のWebアプリケーションは複雑化を極め、ReactのConcurrent Modeや、膨大なサードパーティ製スクリプトが入り乱れている。これらがすべて単一のメインスレッドのタスクキューを奪い合うのだから、何もしなければカオスになるのは当然だ。
ここで、ブラウザの進化が生んだ強力な武器を紹介しよう。`Task Scheduling API` (`scheduler.postTask()`) だ。
従来の `setTimeout(fn, 0)` や `requestIdleCallback` には限界があった。前者は優先度がコントロールできず、後者はブラウザの機嫌次第でいつ実行されるか保証がない。
`scheduler.postTask()` を使えば、タスクに明確な「優先度」を持たせ、イベントループの適切なタイミングにねじ込むことができる。
// ユーザーインタラクションに関係のない重いバックグラウンド処理を安全にスケジュールする
async function processHeavyAnalyticsPayload(payload) {
// ‘background’ 優先度を指定することで、ユーザーの入力やアニメーションを一切ブロックしない
await scheduler.postTask(() => {
// 複雑なJSONのパースや、巨大なデータ構造の集計
heavyTransform(payload);
}, { priority: ‘background’ });
}
// ユーザー入力直後の重要な更新は ‘user-blocking’ または ‘user-visible’
async function handleUserClick(data) {
await scheduler.postTask(() => {
updateCriticalUI(data);
}, { priority: ‘user-visible’ });
}
このAPIの素晴らしいところは、AbortSignal(中断シグナル)もネイティブでサポートしている点だ。ユーザーが別のページに遷移したり、モーダルを閉じたりした瞬間に、未実行の重いバックグラウンドタスクをメモリ上でスマートにキャンセルできる。
—
4. チーフアーキテクトからの提言:メモリ効率とパフォーマンスの極み
最後に、メモリ効率とレンダリング負荷の観点から、プロダクション環境で私たちが守るべき鉄則をいくつか置いておこう。
1. マイクロタスクの乱用に罰金刑を課せ
`Promise` や `async/await` は非常に便利だが、過剰な連鎖はマイクロタスクキューを肥大化させる。特にアニメーションの最中に不必要なPromiseチェインを回さないこと。
2. ResizeObserver と IntersectionObserver を活用せよ
ウィンドウのサイズ変更や要素の可視性を監視する際、自前で `scroll` イベントや `resize` イベントをリスナーに登録して `getBoundingClientRect` を叩くのは「犯罪」に近い。これらは専用のオブザーバーを使い、ブラウザの最適化されたレンダリングサイクルに処理を委譲せよ。
3. メインスレッドを解放せよ (Web Workers)
もしJavaScriptの実行に50ms以上かかる処理(暗号化、画像処理、巨大データのパースなど)があるなら、迷わず `Web Workers`(あるいは `Comlink` などのラッパーライブラリ)へオフロードしろ。メインスレッドは「UIを描画し、ユーザーの操作に0.01秒で応える」という聖域なのだから。
—
結び
Webブラウザの内部挙動、特にイベントループとレンダリングエンジンの協調関係を深く理解することは、単なる「動くコード」を書くフェーズから、「予測可能で極限まで最適化された堅牢なアーキテクチャ」を構築するフェーズへステップアップするためのパスポートだ。
次にコードを書くとき、あるいはパフォーマンスプロファイラーの炎上したタイムラインを眺めるとき、思い出してほしい。
「今、イベントループのどのフェーズで、誰が何をしているのか?」
その視点を持てた瞬間から、あなたの作るWebアプリケーションは、見違えるほど滑らかで、強靭なものに生まれ変わるはずだ。さあ、エディタに戻って、無駄なタスクを刈り取ろうか。

コメント