ブラウザはなぜ動くのか
読了目安 40 分
- HTML から画面が描かれるまでの流れを説明できる
- script の位置が描画に与える影響を説明できる
- setTimeout と Promise の実行順を予測できる
前の章で、リクエストを送って HTML が返ってくるところまでを見ました。 この章では、受け取った HTML がどうやって画面になるのかを見ます。
「なんか遅い」「表示が一瞬崩れる」「setTimeout の順番が思ったのと違う」—— こういう現象は、ブラウザの中で何が起きているかを知らないと直せません。
画面が描かれるまで
ブラウザは、受け取った HTML を上から順に読みながら、次の工程を進めます。
HTML ──パース──→ DOM ツリー ─┐
├─→ レンダーツリー ─→ レイアウト ─→ ペイント ─→ 合成
CSS ──パース──→ CSSOM ツリー ─┘ (どこに何を) (どう塗るか)(重ね合わせ)
| 工程 | やっていること |
|---|---|
| パース | 文字列を木構造に変換する |
| DOM | HTML の構造を表す木 |
| CSSOM | CSS のルールを表す木 |
| レイアウト | 各要素の位置と大きさを計算する(リフローとも言う) |
| ペイント | ピクセルを塗る |
| 合成 | レイヤーを重ねて最終的な画面にする |
重要なのは、この工程が一方通行ではないことです。 JavaScript で要素の幅を変えれば、レイアウトからやり直しになります。
script はなぜ描画を止めるのか
HTML のパース中に <script> に出会うと、ブラウザはパースを止めて
スクリプトをダウンロードし、実行し、終わってからパースを再開します。
<head>
<script src="big.js"></script> ← ここでパースが止まる
</head>
<body>
<h1>ようこそ</h1> ← big.js が終わるまで表示されない
</body>
なぜ止まるかというと、スクリプトが document.write で DOM を書き換えるかもしれないからです。
ブラウザは「先に進んでよいか」を判断できないので、待つしかありません。
async と defer
これを避けるための属性が2つあります。
| 書き方 | ダウンロード | 実行タイミング |
|---|---|---|
<script> | パースを止めて取得 | 取得後すぐ(パースは停止) |
<script async> | 並行して取得 | 取得でき次第すぐ(順序は保証されない) |
<script defer> | 並行して取得 | HTML のパース完了後、記述順に |
<!-- 他のスクリプトに依存しない計測タグなど -->
<script async src="analytics.js"></script>
<!-- DOM を触るアプリのコード。順序も守りたい -->
<script defer src="app.js"></script>アプリケーションのコードはほぼ defer で正解です。
順序が保たれ、DOM が完成してから動くので、
「要素が見つからない」というエラーも起きません。
async は、他と依存関係がなく、いつ動いてもいいものだけに使います。
<link rel="stylesheet"> も描画をブロックします。
CSSOM がないとレンダーツリーが作れないからです。
「真っ白な画面がしばらく続く」原因は、 巨大な CSS や外部フォントの読み込み待ちであることが多いです。
リフローとリペイント
JavaScript や CSS で見た目を変えた時、どこからやり直すかでコストが変わります。
| 変更するもの | 何が起きるか | コスト |
|---|---|---|
width height top margin | レイアウトからやり直し(リフロー) | 高い |
color background | ペイントからやり直し(リペイント) | 中 |
transform opacity | 合成だけ(レイアウトもペイントも不要) | 低い |
だからアニメーションは transform と opacity を使うのが定石です。
/* 遅い: 毎フレーム、要素の位置を計算し直す */
.slide { left: 100px; }
/* 速い: 合成だけで済む */
.slide { transform: translateX(100px); }left を変えると、その要素の位置が変わり、
周りの要素の位置も変わるかもしれないので、全体を計算し直す必要があります。
transform は「すでに描いた絵をずらすだけ」で、
周りに影響しないことが保証されているため、GPU で合成するだけで済みます。
JavaScript はシングルスレッド
ここが後半の主題です。
JavaScript を実行するスレッドは1本しかありません。 そして、そのスレッドは描画も担当しています。
つまり、重い JavaScript が動いている間、画面は完全に固まります。 クリックにも反応しません。
// これを実行すると、5秒間ページが操作不能になる
const end = Date.now() + 5000;
while (Date.now() < end) {}では非同期処理はどう動くのか
1本しかないのに、なぜ setTimeout や fetch が「同時に」動くように見えるのか。
答えはイベントループです。
┌──────────────┐
│ コールスタック │ ← 今実行しているもの(1つだけ)
└──────┬───────┘
│ 空になったら
▼
┌──────────────┐
│ マイクロタスク │ ← Promise の then。空になるまで全部処理する
│ キュー │
└──────┬───────┘
│ 空になったら1つだけ
▼
┌──────────────┐
│ タスクキュー │ ← setTimeout、イベント、fetch の完了
└──────────────┘
規則は3つです。
- コールスタックが空になるまで、他は一切動かない
- 空になったら、マイクロタスクを空になるまで全部処理する
- そのあと、タスクを1つだけ処理する。そしてまた 2 に戻る
実行順を予測してみる
この規則が分かると、次のコードの出力順が説明できます。
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');上のコードの出力順は?
0ミリ秒を指定しても、実行されるのは現在の処理がすべて終わったあとです。 さらにマイクロタスクの後ろに並びます。
「0 なのに遅い」のではなく、「0 は最短待ち時間であって、順番の話ではない」のです。
async / await はどう並ぶのか
await は「そこで一旦関数を抜けて、続きをマイクロタスクに登録する」と考えると理解できます。
async function run() {
console.log('A');
await null; // ここで一旦抜ける
console.log('B'); // 続きはマイクロタスクとして実行
}
run();
console.log('C');出力は A → C → B です。await の後ろが Promise の then と同じ扱いになります。
実務では「状態を更新したのに、直後に読むと古い」という現象で遭遇します。
React の state 更新、DOM の変更後の測定、複数の非同期処理の順序—— すべてイベントループの理解が前提になります。丸暗記ではなく、 「スタック → マイクロタスク → タスク1つ」の3ステップだけ覚えてください。
JS エンジンの中
補足として、JavaScript がどう実行されているかにも触れておきます。
ソースコード → パース → バイトコード → 実行
↓
よく通る箇所は機械語にコンパイル(JIT)
ブラウザの JS エンジン(Chrome なら V8)は、まずバイトコードとして実行し、 何度も通る場所だけを機械語にコンパイルして速くします。これが JIT です。
新人が知っておくべき実務的な意味は1つだけです。 マイクロな最適化より、アルゴリズムとネットワークの方が桁違いに効く、ということ。
for を forEach に変えるより、
API 呼び出しを100回から1回に減らす方が、1000倍効きます。
ブラウザは1プロセスではない
最近のブラウザは、タブごとに別プロセスで動いています。
| プロセス | 役割 |
|---|---|
| ブラウザプロセス | UI、タブ管理、ネットワーク |
| レンダラプロセス | タブの中身。HTML/CSS/JS の実行はここ |
| GPU プロセス | 描画の合成 |
1つのタブが固まっても他のタブが無事なのはこのためです。 そして「JavaScript がシングルスレッド」というのは、 このレンダラプロセスのメインスレッドが1本という意味です。
重い計算を別スレッドで動かす仕組みが Web Worker です。 メインスレッドを止めないので、画面が固まりません。
ただし DOM は触れません(DOM はメインスレッドのもの)。 計算だけを任せて、結果をメッセージで返す使い方をします。
パフォーマンスの見方
開発者ツールの Performance タブと、覚えておくべき指標です。
| 指標 | 意味 |
|---|---|
| LCP | 一番大きな要素が表示されるまでの時間。「表示された」と感じる瞬間 |
| CLS | 表示中にレイアウトがガタつく量。画像のサイズ未指定が主犯 |
| INP | 操作してから反応するまでの時間。重い JS が主犯 |
画像のサイズを指定しないと、読み込み完了まで高さが 0 として扱われ、 読み込んだ瞬間に下の要素が押し下げられます(CLS の悪化)。
読もうとした文章がずれる、押そうとしたボタンが動く、という体験の原因です。
実務の落とし穴まとめ
<script>の位置 — head に置くと描画が止まる。deferを使うsetTimeout(fn, 0)は即時ではない — マイクロタスクの後ろ- 重い同期処理で画面が固まる — 分割するか Web Worker へ
leftでアニメーションする —transformを使う- 画像のサイズ未指定 — レイアウトがガタつく
- マイクロ最適化に時間を使う — まずネットワークとアルゴリズムを見る
まとめ
- HTML → DOM、CSS → CSSOM、合わせてレイアウト → ペイント → 合成で画面になる
<script>はパースを止める。アプリのコードはdefertransform/opacityは合成だけで済むので速い- JS はシングルスレッド。重い処理は画面を止める
- イベントループは 「スタック → マイクロタスク全部 → タスク1つ」 の繰り返し
awaitの後ろはマイクロタスク。setTimeoutはタスク
章末問題
次のコードの出力順は? console.log('start'); setTimeout(() => console.log('timeout')); Promise.resolve().then(() => console.log('promise')); console.log('end');
ページを開くと数秒間まっしろで、その後いきなり全部表示されます。最初に疑うのは?
要素を横に動かすアニメーションが、スマホでカクつきます。改善策は?
次の章では、ここまで出てきた JavaScript に型をつける TypeScript を扱います。