プログラマのための IT 教科書
第3部 コンピュータとネットワークの基礎

ブラウザはなぜ動くのか

読了目安 40

この章を読むとできるようになること
  • HTML から画面が描かれるまでの流れを説明できる
  • script の位置が描画に与える影響を説明できる
  • setTimeout と Promise の実行順を予測できる

前の章で、リクエストを送って HTML が返ってくるところまでを見ました。 この章では、受け取った HTML がどうやって画面になるのかを見ます。

「なんか遅い」「表示が一瞬崩れる」「setTimeout の順番が思ったのと違う」—— こういう現象は、ブラウザの中で何が起きているかを知らないと直せません。

画面が描かれるまで

ブラウザは、受け取った HTML を上から順に読みながら、次の工程を進めます。

HTML  ──パース──→  DOM ツリー ─┐
                              ├─→ レンダーツリー ─→ レイアウト ─→ ペイント ─→ 合成
CSS   ──パース──→ CSSOM ツリー ─┘        (どこに何を)  (どう塗るか)(重ね合わせ)
工程やっていること
パース文字列を木構造に変換する
DOMHTML の構造を表す木
CSSOMCSS のルールを表す木
レイアウト各要素の位置と大きさを計算する(リフローとも言う)
ペイントピクセルを塗る
合成レイヤーを重ねて最終的な画面にする

重要なのは、この工程が一方通行ではないことです。 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

アプリケーションのコードはほぼ defer で正解です。 順序が保たれ、DOM が完成してから動くので、 「要素が見つからない」というエラーも起きません。

async は、他と依存関係がなく、いつ動いてもいいものだけに使います。

CSS も描画を止める

<link rel="stylesheet"> も描画をブロックします。 CSSOM がないとレンダーツリーが作れないからです。

「真っ白な画面がしばらく続く」原因は、 巨大な CSS や外部フォントの読み込み待ちであることが多いです。

リフローとリペイント

JavaScript や CSS で見た目を変えた時、どこからやり直すかでコストが変わります。

変更するもの何が起きるかコスト
width height top marginレイアウトからやり直し(リフロー)高い
color backgroundペイントからやり直し(リペイント)
transform opacity合成だけ(レイアウトもペイントも不要)低い

だからアニメーションは transformopacity を使うのが定石です。

/* 遅い: 毎フレーム、要素の位置を計算し直す */
.slide { left: 100px; }
 
/* 速い: 合成だけで済む */
.slide { transform: translateX(100px); }
なぜ transform だけ速いのか

left を変えると、その要素の位置が変わり、 周りの要素の位置も変わるかもしれないので、全体を計算し直す必要があります。

transform は「すでに描いた絵をずらすだけ」で、 周りに影響しないことが保証されているため、GPU で合成するだけで済みます。

JavaScript はシングルスレッド

ここが後半の主題です。

JavaScript を実行するスレッドは1本しかありません。 そして、そのスレッドは描画も担当しています

つまり、重い JavaScript が動いている間、画面は完全に固まります。 クリックにも反応しません。

// これを実行すると、5秒間ページが操作不能になる
const end = Date.now() + 5000;
while (Date.now() < end) {}

では非同期処理はどう動くのか

1本しかないのに、なぜ setTimeoutfetch が「同時に」動くように見えるのか。 答えはイベントループです。

        ┌──────────────┐
        │ コールスタック │ ← 今実行しているもの(1つだけ)
        └──────┬───────┘
               │ 空になったら
               ▼
        ┌──────────────┐
        │ マイクロタスク │ ← Promise の then。空になるまで全部処理する
        │    キュー     │
        └──────┬───────┘
               │ 空になったら1つだけ
               ▼
        ┌──────────────┐
        │  タスクキュー  │ ← setTimeout、イベント、fetch の完了
        └──────────────┘

規則は3つです。

  1. コールスタックが空になるまで、他は一切動かない
  2. 空になったら、マイクロタスクを空になるまで全部処理する
  3. そのあと、タスクを1つだけ処理する。そしてまた 2 に戻る

実行順を予測してみる

この規則が分かると、次のコードの出力順が説明できます。

console.log('1');
 
setTimeout(() => console.log('2'), 0);
 
Promise.resolve().then(() => console.log('3'));
 
console.log('4');

上のコードの出力順は?

setTimeout(fn, 0) は「すぐ」ではない

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つだけです。 マイクロな最適化より、アルゴリズムとネットワークの方が桁違いに効く、ということ。

forforEach に変えるより、 API 呼び出しを100回から1回に減らす方が、1000倍効きます。

ブラウザは1プロセスではない

最近のブラウザは、タブごとに別プロセスで動いています。

プロセス役割
ブラウザプロセスUI、タブ管理、ネットワーク
レンダラプロセスタブの中身。HTML/CSS/JS の実行はここ
GPU プロセス描画の合成

1つのタブが固まっても他のタブが無事なのはこのためです。 そして「JavaScript がシングルスレッド」というのは、 このレンダラプロセスのメインスレッドが1本という意味です。

Web Worker

重い計算を別スレッドで動かす仕組みが Web Worker です。 メインスレッドを止めないので、画面が固まりません。

ただし DOM は触れません(DOM はメインスレッドのもの)。 計算だけを任せて、結果をメッセージで返す使い方をします。

パフォーマンスの見方

開発者ツールの Performance タブと、覚えておくべき指標です。

指標意味
LCP一番大きな要素が表示されるまでの時間。「表示された」と感じる瞬間
CLS表示中にレイアウトがガタつく量。画像のサイズ未指定が主犯
INP操作してから反応するまでの時間。重い JS が主犯
画像に width / height を書く

画像のサイズを指定しないと、読み込み完了まで高さが 0 として扱われ、 読み込んだ瞬間に下の要素が押し下げられます(CLS の悪化)。

読もうとした文章がずれる、押そうとしたボタンが動く、という体験の原因です。

実務の落とし穴まとめ

  1. <script> の位置 — head に置くと描画が止まる。defer を使う
  2. setTimeout(fn, 0) は即時ではない — マイクロタスクの後ろ
  3. 重い同期処理で画面が固まる — 分割するか Web Worker へ
  4. left でアニメーションするtransform を使う
  5. 画像のサイズ未指定 — レイアウトがガタつく
  6. マイクロ最適化に時間を使う — まずネットワークとアルゴリズムを見る

まとめ

  • HTML → DOM、CSS → CSSOM、合わせてレイアウト → ペイント → 合成で画面になる
  • <script> はパースを止める。アプリのコードは defer
  • transform / opacity は合成だけで済むので速い
  • JS はシングルスレッド。重い処理は画面を止める
  • イベントループは 「スタック → マイクロタスク全部 → タスク1つ」 の繰り返し
  • await の後ろはマイクロタスク。setTimeout はタスク

章末問題

次のコードの出力順は? console.log('start'); setTimeout(() => console.log('timeout')); Promise.resolve().then(() => console.log('promise')); console.log('end');

ページを開くと数秒間まっしろで、その後いきなり全部表示されます。最初に疑うのは?

要素を横に動かすアニメーションが、スマホでカクつきます。改善策は?

次の章では、ここまで出てきた JavaScript に型をつける TypeScript を扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。