ブラウザの仕組み — 設計図を受け取って画面を組み立てる

上級の解説は準備中のため、上級の内容を表示しています。

概要 — まず全体をつかむ

中級では描画パイプラインとセキュリティ境界を見ました。上級では、描画とJS実行が奪い合う「メインスレッド1本」を主役に据えます。なぜ重いJSやレイアウト連発で画面が固まるのか——その正体を、パイプライン・JSエンジン・イベントループの3方向から掘り下げます。

詳細 — 1段階ずつ追う

描画パイプラインを一段深く

中級のパイプライン(パース→レンダーツリー→レイアウト→ペイント→合成)を、性能に効く粒度で見直します。

  • HTMLの増分パース — ブラウザはHTMLを最後まで待たず、届いた分から少しずつDOMを組み立てる。さらにプリロードスキャナが本文解析と並行して先読みし、後方の画像やCSSを早めに取りに行く
  • レンダーブロッキングなCSS — CSSはCSSOMが完成するまで最初のペイントを止める。CSSが「描画をブロックする資源」と呼ばれるのはこのため。逆にHTMLパースは基本止めない
  • レイアウト(リフロー) — 各要素の位置と大きさを幾何計算する。要素数や入れ子が深いほど重い
  • ペイント — 塗る内容(色・文字・影)をレイヤーごとのビットマップに変換する
  • コンポジット(合成) — 複数のレイヤーを重ねて最終画面を作る。transformopacity の変化は、レイアウトもペイントもやり直さず合成だけで済むことが多く、だからアニメーションはこの2プロパティが速い
アニメーション『ブラウザの描画パイプライン』を開く
ブラウザの描画パイプラインHTML・CSS から、順に段を通って画面のピクセルになるDOM構造CSSOM見た目レイアウト位置と大きさペイントピクセル化合成重ね合わせJS(メインスレッド)上の各段と同じ1本のスレッドで動く — JSが重いと、この描画パイプラインも止まる構造(DOM)と見た目(CSSOM)を合わせ、位置を計算し、塗って、重ねて表示する

図のように、DOM→CSSOM→レイアウト→ペイント→合成の順にハイライトが流れます。そして、これら各段とJSは同じ1本のメインスレッドで回るため、JSが重いとパイプライン全体が止まります。

この一連を「最初の意味あるピクセルを出すまでに、どれだけ最短で通せるか」で捉えたのがクリティカルレンダリングパス(※1)です。HTMLとレンダーブロッキングCSSと同期JSが最初の描画までのボトルネックで、ここを削る(CSSを小さく・JSを遅延)と初期表示が速くなります。

登場人物メモ

  • ※1 クリティカルレンダリングパス — URL取得から最初のピクセル描画までに、必ず通らねばならない資源と処理の連なり

なお、そのDOMを誰がどこで組み立てるか(サーバかブラウザか)は初期表示コストを大きく変えます。ブラウザ側でJSがDOMを組む方式(CSR)は、下の図でいう右側に寄るほどメインスレッドの初期負荷が増えます。

アニメーション『HTMLをどこで組み立てるか』を開く
HTMLは「いつ・どこで」組み立てる?ビルド → サーバ → ブラウザ の3段のうち、★でHTMLが完成する位置がちがうSSGビルド時に生成🏗️ビルドHTMLを前もって生成★ HTML完成🖥️サーバ静的ファイルを配るだけ🌐ブラウザ受け取って表示速い・全員同じSSRリクエストごとに生成🏗️ビルド土台を用意するだけ🖥️サーバ毎回HTMLを組み立て★ HTML完成🌐ブラウザ受け取って表示毎回最新・個別化CSRブラウザ側で生成🏗️ビルドJSをまとめる🖥️サーバ空のHTML+JSを渡す🌐ブラウザJSがHTMLを組み立て★ HTML完成操作の重いUI向き

JSエンジンの中身

JavaScriptは「その場で1行ずつ解釈」だけではありません。現代のエンジンは多段構成で速度を稼ぎます。

  1. パース — ソースを抽象構文木(AST)に変換する。関数は必要になるまで本体を詳しく読まない遅延パースもある
  2. バイトコード化 — ASTを、インタプリタ用の中間表現(バイトコード)へ落とす
  3. インタプリタ実行 — まずバイトコードを解釈しながら動かし、同時に「どの関数がよく呼ばれ、どんな型で来るか」を計測する
  4. JIT最適化(※1) — 頻繁に走るホットなコードを、計測した型を前提に機械語へ最適化コンパイルする。速いが「この型で来る」という賭けの上に立つ
  5. 脱最適化 — 賭けが外れた(想定と違う型が来た)ら、最適化コードを捨ててインタプリタへ戻す。型がバラバラなコードは最適化が効きにくい

加えてガベージコレクション(※2)が、参照されなくなったメモリを自動回収します。回収処理はメインスレッドを一瞬奪うため、大量のオブジェクト生成はGCの間のカクつきとして現れることがあります。

登場人物メモ

  • ※1 JIT — Just-In-Time。実行しながら熱い部分だけをその場で機械語に最適化する方式
  • ※2 ガベージコレクション — もう使われないメモリを自動で見つけて回収する仕組み

イベントループとタスクの優先順位

ブラウザの描画とJS実行は、原則単一のメインスレッドを共有します。その調停役がイベントループです。

  • コールスタック — いま実行中のJSが積まれる場所。ここが空にならないと次へ進めない
  • マクロタスクキューsetTimeout・イベントハンドラ・ネットワーク完了など、1周に1つ取り出して実行する待ち行列
  • マイクロタスクキュー — PromiseのthenqueueMicrotaskなど。1つのタスクを終えるたびに、空になるまで一気に処理される(マクロタスクより優先)
  • レンダリングの機会 — 1周の中で、マイクロタスクを片づけたあとに「描いてよい隙間」が来る(おおむね画面更新に合わせて)
アニメーション『イベントループ(単一メインスレッド)』を開く
単一メインスレッドとイベントループタスクキュー(順番待ちの行列)① クリック② タイマー③ 再描画(paint)④ 入力(キー)先に入ったものから 1つずつ③④ は処理されず待ち先頭を1つ渡すイベントループ空いたら次を取り出すメインスレッド(1本だけ)重いJSタスク実行中・長く居座る終わるまで離さない1本しかないのでこれが終わるまで次へ進めない再描画が止まる入力に反応しない画面が固まる描画も入力も反応しない(フリーズ)JSと描画は同じ1本のスレッドを奪い合う

順序を1文でいうと——1タスク実行 → マイクロタスク全消化 → 必要なら描画 → 次のタスク。だからPromise.thensetTimeout(fn,0)より必ず先に走ります。そしてコールスタックが長時間ふさがると、この輪が回らず描画も入力も止まる——これが「固まる」の正体です。目安として、1つのタスクが約50msを超えると応答性の悪化(ロングタスク)として体感されます。

同一オリジンポリシーとCORS(深掘り)

中級では「別オリジンは既定で制限」と押さえました。上級では、その境界の定義越え方の手順を精密にします。

  • オリジンの定義スキーム・ホスト・ポートの3つ組が完全一致してはじめて同一オリジン。https://a.comhttp://a.com(スキーム違い)、a.comapi.a.com(ホスト違い)はすべて別オリジン
  • 同一オリジンポリシー(SOP) — 別オリジンの応答中身をJSから読ませない基本ルール。画像や<script>読み込み自体は許すが、fetchで中身を読むのは別
  • CORS — サーバが応答ヘッダ(Access-Control-Allow-Originなど)で「このオリジンには中身を見せてよい」と明示的に許可する仕組み
  • プリフライト(※1) — 危険になりうる要求(独自ヘッダやPUTDELETEなど)では、本番の前にOPTIONSメソッドで予備問い合わせを送り、サーバの許可を先に確かめる。単純なGETなどは省略される
  • 資格情報つき — Cookieを乗せて跨ぐには、送る側の設定に加えサーバ側のAccess-Control-Allow-Credentialsが要り、Allow-Origin*は使えない(オリジンを名指しする)

この境界は攻撃対策と表裏一体です。緩めすぎたCORSは情報漏れの穴になり、SOPが崩れればXSSの被害も広がります。別オリジンのAPIを叩くときに出る「CORSエラー」は、この許可設定の不足そのものです。

登場人物メモ

  • ※1 プリフライト — 本番リクエストの前にOPTIONSで「送ってよいか」を確かめる予備の往復

⚠️ なぜ固まるのか — 上級の落とし穴

  • ロングタスク — 1つのJSタスクが長く、その間コールスタックが空かず描画も入力も止まる。処理を分割し、隙間を返す
  • 強制同期レイアウト(レイアウトスラッシング) — 幾何情報の読み取り(offsetWidthなど)と書き込みを交互に繰り返し、レイアウトを何度も強制計算させる。読みをまとめてから書きに直す
  • 合成できないアニメーションwidthtopを毎フレーム変えるとレイアウト+ペイントが走り重い。transformopacityなら合成だけで済む
  • マイクロタスクの暴走 — Promiseチェーンが自分自身を延々と積むと、マイクロタスクが尽きず描画の隙間が来ない(見かけ上フリーズ)
  • メモリリークによるGC多発 — 外れない参照(消し忘れたイベントリスナ等)でオブジェクトが溜まり、GCが頻発してカクつく

ここまでの「1本のメインスレッドをどう空けるか」という視点は、URLを打ってから表示されるまでの④「組み立てる」を、性能の観点から裏返したものです。

理解度チェック

そのまま解けます(成績は保存されません)。無料アカウントを作ると、学習の記録と進捗の山登りが始まります。

1. Promiseのthenコールバックと、setTimeout(0ミリ秒)のコールバック。1つのタスクを実行し終えた直後、次のタスクやレンダリングより先に処理されるのはどちらか?

2. ループの中で要素の幅(offsetWidth)を読み、その直後にスタイルを書き換える——これを繰り返すと極端に遅くなる。主な理由は?

3. 2つのURLが「同一オリジン」と判定されるのはどんなときか?

4. アニメーションで「レイアウトもペイントもやり直さず、合成だけで済む」ことが多く、だから軽いプロパティの組み合わせはどれか?

5. JITが「この型で来る」と賭けて機械語に最適化した後、想定と違う型が来たため最適化コードを捨ててインタプリタへ戻すことを何と呼ぶか?

6. 次のうち「最初のペイントを止める(レンダーブロッキング)」資源として正しいのはどれ?

7. 本番リクエストの前に、OPTIONSメソッドで「その要求を送ってよいか」をサーバへ確かめる予備の往復を何と呼ぶ?

8. URL取得から最初のピクセル描画までに、必ず通らねばならない資源と処理の連なりを何と呼ぶ?

ブラウザの仕組み — 設計図を受け取って画面を組み立てる | Kotowary