Tech Learning Daily

2026-08-09 (Sun) — 第 24 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Frontend 🌿 基礎 ⏱ 約 7 分

CSS は head に、JS は defer で読むべきなのはなぜか? ブラウザの描画パイプラインを覗く

Lighthouse のレポートで「レンダリングを妨げるリソース」という警告を見て、<script> タグをとりあえず </body> の直前に動かした経験があるはずだ。それで表示は速くなったが、なぜ効いたのか、defer 属性を使えば <head> に置いたままでも同じ効果が得られると聞いて戸惑わなかっただろうか。

🎯 3 行まとめ
  • HTML パーサーは解析しながら DOM を組み立てるが、<script> に達すると defer/async が無い限りダウンロードと実行が終わるまで解析を止める。
  • CSS は後に書かれたルールが前のルールを上書きしうるため、CSSOM はスタイルシート全体を読み終えるまで完成せず、完成するまで画面は描画されない。
  • レイアウトとペイントを飛ばしてコンポジットだけで完結する transformopacity の変更は、幅や位置を変える変更より軽い。

HTML パーサーは上から順に、しかも先読みしながら DOM を作る

ブラウザは受信した HTML を字句解析してタグをトークンに分解し、トークンから DOM(Document Object Model、HTML をオブジェクトの木構造にしたもの)のノードを 1 つずつ組み立てる。この処理はページ全体を待ってから一括で行うのではなく、サーバーからレスポンスが少しずつ届くたびに、届いた分だけ解析を進めてストリーミングで反映していく。

ただしこの解析には例外がある。<script> タグに達すると、パーサーはそこで手を止める。JavaScript が document.write() で HTML を書き換えたり、まだ組み立てていないノードを参照したりする可能性があるため、ブラウザは「スクリプトの実行が終わるまで DOM がどう変わるか確定しない」という前提で動くからだ。src 付きの通常の <script> は、ダウンロードと実行の両方が終わるまで解析を再開しない。<head> に大きな JS ファイルを置くと、それより下の HTML が一切解析されず画面が長く白いままになるのは、この停止が原因である。

CSSOM は「全部読むまで」完成しない

CSS も同様にオブジェクトモデル化され、これを CSSOM(CSS Object Model)と呼ぶ。だが CSSOM の構築は DOM のように少しずつ確定させていくわけにはいかない。カスケード(同じ詳細度なら後に書かれたルールが前のルールを上書きする仕組み)があるため、スタイルシートの最後の行を読むまで、どの宣言が最終的に効くか決まらないからだ。

この性質のせいで CSS はレンダーブロッキング(描画をブロックする)リソース扱いになる。CSSOM が完成しないと後述のレンダーツリーが作れず、レンダーツリーが無ければ画面に何も描けない。HTML の解析自体は止めないが、「描画できる状態になる」タイミングを CSSOM の完成まで遅らせる。<link rel="stylesheet"><head> に置く昔ながらの作法は、これを早い段階で確定させ、後からスタイルが変わって画面がちらつく現象(FOUC、Flash of Unstyled Content)を避けるためのものだ。

defer と async は「止めない」が、止め方が違う

通常の <script> がパーサーを止めてしまう問題への対処が deferasync という 2 つの属性だ。どちらもダウンロード自体は HTML 解析と並行して進む点は共通だが、実行タイミングが異なる。

defer を付けたスクリプトはダウンロードが終わっていても実行を待たされ、HTML 解析がすべて終わってから記述順に実行される。DOM に依存するコードや、互いに依存し合う複数のスクリプトを安全に読み込みたいときはこちらを使う。defer 付きスクリプトの実行完了は DOMContentLoaded イベントの発火条件に含まれるため、そのイベントを起点に初期化処理を書いても順序は保証される。一方 async はダウンロードが終わった時点で他の処理に割り込んで即座に実行される。解析中であっても割り込むため、複数の async スクリプトがあると実行順序はダウンロード完了の早い者勝ちになり保証されない。広告タグやアクセス解析のように他のコードに依存しない独立したスクリプトに向く。

<script src="app.js"></script>       <!-- 解析を止めて実行 -->
<script defer src="app.js"></script> <!-- 解析後、順番通りに実行 -->
<script async src="ads.js"></script> <!-- 準備でき次第、即実行 -->

</body> の直前にスクリプトを置く古い作法が効くのも、パーサーが script に到達する前に残りの HTML をすでに解析し終えているからにすぎない。defer を使えば、同じ効果を <head> に置いたまま得られる。置き場所そのものが本質ではなく、パーサーを止めるかどうかが本質だ。

DOM と CSSOM が揃うとレンダーツリーになり、レイアウト・ペイントへ進む

DOM と CSSOM が揃うと、ブラウザは両者を掛け合わせてレンダーツリーを作る。ここで display: none が指定されたノードは除外される。画面に描く必要が無いノードを最初から持たないことで、後段の計算量を減らすためだ。似た指定に visibility: hidden があるが、こちらは要素を透明にするだけでレイアウト上の場所は占有し続けるため、レンダーツリーには残る。

HTML─┐          CSS─┐
     ▼               ▼
    DOM            CSSOM
     └──────┬──────┘
            ▼
      レンダーツリー
            ▼
     レイアウト(reflow)
            ▼
         ペイント
            ▼
      コンポジット(GPU)
            ▼
           画面

レンダーツリーができるとリフロー(レイアウトの再計算とも呼ばれる)が走り、各ノードの正確な位置とサイズをピクセル単位で計算する。続くペイントで、その位置に実際の色や形を描き込む。ここまではメインスレッド上を直列に進む処理で、扱うノード数が多いほど時間がかかる。

🍱 たとえるなら

最後の合成(コンポジット)は、透明フィルムに描いた絵を重ねて 1 枚に見せる作業に近い。テロップの位置だけをずらしたい動画編集なら、背景の絵を描き直さずテロップのレイヤーだけ動かして重ね直せば済む。transformopacity の変更が軽いのはこれと同じで、対象要素が独立したレイヤーに乗っていれば、採寸(レイアウト)のやり直しも絵の描き直し(ペイント)も要らず、レイヤーの重ね合わせだけをやり直せばいい。逆に幅や位置を変える操作は、そのレイヤーの絵そのものを描き直す採寸からのやり直しになる。

transform と opacity だけが「コンポジット止まり」で済む

最後のコンポジット(合成)は、ペイント済みの各レイヤーを正しい順序で重ね合わせて画面に渡す工程で、GPU が担当する。ここにブラウザ実装上の特別扱いがある。transformopacity の変更は、対象要素が独立したレイヤーに乗っていれば、レイアウトの再計算もペイントのやり直しも必要とせず、コンポジットだけで完結する。

/* レイアウトから再計算される: 重い */
.box { transition: left 0.3s, width 0.3s; }

/* コンポジットだけで完結しうる: 軽い */
.box { transition: transform 0.3s, opacity 0.3s; }

leftwidth はノードの位置・サイズそのものを変えるため、レイアウトからやり直すほかない。アニメーションを滑らかに保ちたい場面で transform: translateX() が推奨されるのは、この省略できる工程の多さが理由であり、単なる好みの問題ではない。

💼 実務でどう出会うか

Lighthouse や PageSpeed Insights の「レンダリングを妨げるリソースの除外」という指摘は、まさにここまでの内容そのものだ。CSS はクリティカルな部分だけをインライン化して残りを非同期読み込みにする、JS には defer を付ける、といった対処の理由が説明できるようになる。ホバーやトグルのアニメーションがカクつくときも、DevTools の Performance タブで紫(Layout)と緑(Paint)の帯が長ければ、transformopacity だけで書き直せないか疑うのが最初の一手になる。

⌨️ 手を動かす(5 分)

レイアウト・ペイントとコンポジットのコスト差を、実際に目で見て確認する。

1. Chrome で任意のページを開く(このサイトでもよい)
2. macOS: Cmd+Option+I で DevTools を開く
3. Cmd+Shift+P → "Show Rendering" を実行
4. Rendering パネルで "Paint flashing" にチェック
5. ページをスクロールしたり、
   ホバーで見た目が変わる要素を操作する

再ペイントされた領域が緑色にハイライトされる。ページ全体をスクロールしただけならほとんど光らない(コンポジットだけで処理されている)一方、ホバーで背景色が変わるボタンなどは操作のたびに緑に光るはずだ。実際にペイントが走っている証拠を自分の目で確認できる。

🙅 よくある誤解
  • CSS は head に、JS は body の末尾に書けばそれで十分だ — 置き場所自体が効いているのではなく、パーサーを止めないことが本質。defer を使えば head に置いたままでも同じ効果が得られる。
  • async も defer も同じくらい安全に使い回せる — async は実行順序を保証しないため、DOM 依存や互いに依存し合うスクリプトに使うと実行順序次第で壊れる。
  • DOM 操作やスタイル変更は全般的に重い — 実際に重いのはレイアウトを再計算させる変更(幅・位置など)で、transform・opacity だけの変更はコンポジットだけで完結し軽い。
📖 用語ミニ辞典
DOM
HTML をオブジェクトの木構造として表現したもの。
CSSOM
CSS をオブジェクトの木構造として表現したもの。スタイルシート全体を読むまで完成しない。
レンダーツリー
DOM と CSSOM を掛け合わせた、画面に描く必要があるノードだけの木構造。
リフロー
各ノードの位置・サイズをピクセル単位で再計算する処理。レイアウトとも呼ぶ。
コンポジット
ペイント済みのレイヤーを正しい順序で重ね合わせて画面に渡す最終工程。GPU が担当する。
レンダーブロッキング
その資源が読み込み終わるまで画面の描画が始まらない性質。CSS はこれに該当する。
🔗 もっと深く