CSSで表示を速くする — セレクタの最適化は、条件を揃えて測ったら0.1msだった
先に結論
「子孫セレクタは遅いから避ける」「IDセレクタが最速」という助言が、いまも検索結果の上位に並んでいます。 条件を揃えて測ったら、差は0.1ミリ秒でした。
| 助言 | いま |
|---|---|
| 子孫セレクタを避ける | 効果はほぼ無い。測ったら0.1msの差(詳しく) |
| IDセレクタを使う | 速く見えるのは対象要素が少ないから(詳しく) |
| セレクタは右から左に解釈される | 事実だが、だから遅いという結論にはならない |
| CSS3の使用は最小限に | いまは前提が違う。使って問題ない |
| CSSの読み込みがレンダリングを止める | これはいまも効く。本命(詳しく) |
手を付ける順番は、こうなります。
| 順 | やること | 効くもの |
|---|---|---|
| 1 | CSSの読み込みがレンダリングを止める時間を減らす | LCP・FCP |
| 2 | レイアウトのずれを無くす(画像に寸法を書く等) | CLS |
| 3 | 画面外の描画を遅らせる(content-visibility) | 長いページの初期表示 |
| 4 | 再フローを減らす(読み書きの順番) | 操作の反応(INP)。945倍の差が出た |
| 最後 | セレクタを簡潔にする | 保守性。速度はほぼ変わらない |
セレクタの最適化を測ってみた
条件を揃えて比べる
セレクタの速度を比べるとき、うっかりすると「何要素にマッチするか」を比べてしまいます。 IDは1要素、クラスは数千要素、では比較になりません。
そこで、すべて同じ2,000要素にマッチするセレクタを用意しました。 DOM全体は12,008要素です。スタイルを差し替えて、再計算が終わるまでの時間を51回測り、中央値を取りました。
| セレクタ | マッチ数 | 中央値 |
|---|---|---|
.t | 2,000 | 1.8ms |
span.t | 2,000 | 1.8ms |
li .t | 2,000 | 1.8ms |
.wrap section article ul li span.t(4段) | 2,000 | 1.9ms |
[id^="t"](属性の前方一致) | 2,000 | 1.9ms |
:not(.zzz).t | 2,000 | 1.8ms |
最速と最遅の差は 0.1ミリ秒でした。 12,008要素のDOMで、2,000要素を一斉に塗り直して、この差です。 「子孫セレクタ4段を避ける」ことで得られるのは、この0.1msです。
「IDが最速」の正体
条件を揃えない比較もやってみました。こちらだと、たしかにIDが速く見えます。
| セレクタ | マッチ数 | 中央値 |
|---|---|---|
#t1999 | 1 | 0.7ms |
.t | 2,000 | 1.8ms |
| 4段の子孫セレクタ | 2,000 | 2.0ms |
0.7msと1.8msの差は、セレクタの複雑さではありません。 1要素を塗り直すか、2,000要素を塗り直すかの差です。
「IDセレクタが最速」という助言は、この2つを混同しています。 同じ数の要素にスタイルを当てるなら、表3のとおり、 書き方を変えてもほとんど変わりません。
MDNは何と言っているか
公平に書いておくと、MDNもセレクタの簡略化は勧めています。 ただし、理由の並べ方を見てください。
「セレクターを簡略化することが重要である。複雑なセレクターは、パフォーマンスに悪影響を与え、
メンテナンスも難しくなる」
— CSS のパフォーマンス最適化(MDN・要約)
そしてMDNは、CSSの主な課題として別のものを挙げています。
「CSSが引き起こす主なパフォーマンス課題としては、レンダリングブロックと再フローの最小化が挙げられる」
— 同上
つまり「セレクタを簡潔に書く」は正しい助言です。 ただし、速度のためではなく保守のためと考えたほうが実態に合います。 速度で効くのは、MDNが先に挙げている2つです。
測る指標が変わった
いまの3つ
2010年代前半は「表示が速い/遅い」を体感で語っていました。 いまは測り方が決まっています。
| 指標 | 意味 | 「良好」の基準 |
|---|---|---|
| LCP(Largest Contentful Paint) | 一番大きい要素が描画されるまで | 2.5秒 |
| INP(Interaction to Next Paint) | 操作してから次の描画まで | 200ミリ秒 |
| CLS(Cumulative Layout Shift) | レイアウトのずれの量 | 0.1 |
判定は75パーセンタイルで行われます。 平均ではありません。遅いほうの4人に1人がどうかを見ます。 「自分の環境では速い」が通用しないのはこのためです。
FIDは廃止された
検索結果にはいまも FID を挙げている解説がありますが、これは置き換わっています。
「2022年5月に試験運用版の指標として Interaction to Next Paint(INP)を導入しました。
昨年、INPは保留中の指標となり、2024年3月にINPが安定ステータスに昇格することを発表しました。
本日、INPが公式にCore Web Vitalsとして3月12日にFIDに代わり、この移行に伴いFIDが廃止されることを……」
— 3月12日に「Interaction to Next Paint」がウェブに関する主な指標に(web.dev・要約)
FIDと書いてある解説は、2024年3月より前の情報です。 その記事の他の内容も、同じだけ古い可能性があります。
CSSで本当に効くのはレンダリングブロック
なぜ止まるのか
CSSは、読み終わるまで描画を止めます。 スタイルが分からないうちに描くと、あとから見た目が変わってしまうからです。
「CSSは、ブラウザーがCSSを要求していると判断するまでレンダリングをブロックする」
— CSS のパフォーマンス最適化(MDN・要約)
だから、CSSのサイズと本数がそのまま最初の描画の遅さになります。 セレクタの書き方は、そのあとの話です。
このサイトの記事ページで測った値を載せておきます。
| 項目 | 値 |
|---|---|
| DOM要素数 | 642 |
| 同一オリジンのリクエスト数 | 2(HTML+CSS) |
| CSSの本数・サイズ | 1本・15,663バイト |
| FCP(最初の描画) | 176ms |
| LCP | 176ms |
| LCPの要素 | h1 |
FCPとLCPが同じ値になっています。
画像が無く、一番大きい要素が h1 のテキストなので、
最初の描画がそのまま最大要素の描画になるからです。
この数字はローカルサーバーでの測定なので、絶対値は参考になりません。 意味があるのは関係のほうです。止めているものがCSS1本しかないページでは、 LCPを縮める余地もCSSの読み込みにしかありません。
メディアクエリで非ブロッキングにする
MDNが挙げている手が、これです。
「メディアクエリーを使用して非ブロッキングのCSSを作成し、特定の条件にのみ適用されるようにすることで、
レンダリングブロックを最小限に抑えることができる」
— 同上
<!-- 常に要るものだけをブロッキングにする -->
<link rel="stylesheet" href="/base.css">
<!-- 印刷用・広い画面用は、条件が合うときだけブロックする -->
<link rel="stylesheet" href="/print.css" media="print">
<link rel="stylesheet" href="/wide.css" media="(min-width: 1200px)">
| 手 | 効果 | 注意 |
|---|---|---|
| CSSを1本にまとめる | 往復が減る | サイズが大きくなりすぎると逆効果 |
media で条件を付ける | 条件外ではブロックしない | MDNが挙げている手 |
| 最初の画面ぶんをインラインにする | 往復が0になる | HTMLが毎回大きくなる。やりすぎない |
| 使っていないCSSを消す | サイズが減る | 消しすぎると壊れる。計測しながら |
@import をやめる | 直列の往復が消える | CSSの中からさらにCSSを呼ぶ形はいまも遅い |
最後の行は、昔から変わらず有効です。
@import は、1本目を読み終わってから2本目を取りに行くので、待ち時間が足し算になります。
<link> を並べれば同時に取りに行けます。
もう一方の課題、再フロー
MDNが挙げるもう一方が再フローです。 こちらは、セレクタとは桁が違いました。
2,000個の要素に対して、①高さを読んで、すぐ高さを書くを交互に繰り返す場合と、 ②全部読み終えてから、全部書く場合を測りました。やっている仕事は同じです。
| やり方 | 中央値 |
|---|---|
| 読む→書く を交互に繰り返す | 377.9ms |
| 全部読む→全部書く | 0.4ms |
| 差 | 約945倍 |
セレクタの書き換えで取れるのが0.1ms、順番を直すと378msです。 どちらに時間を使うべきかは、この2つを並べれば決まります。
なぜこうなるか。offsetHeight のような値を読もうとすると、
ブラウザは「いま正しい値」を返すために、保留していたレイアウト計算を強制的に終わらせます。
書いた直後に読めば、また計算させることになります。2,000回繰り返せば、2,000回計算させます。
// 遅い:読む→書く を交互に(2,000要素で 378ms)
for (const el of els) {
el.style.height = el.offsetHeight + 1 + 'px'; // 読んだ直後に書いている
}
// 速い:読み切ってから、書き切る(同じ処理で 0.4ms)
const hs = els.map(el => el.offsetHeight); // まず全部読む
els.forEach((el, i) => { el.style.height = hs[i] + 1 + 'px'; });
レイアウトを強制的に計算させるプロパティを覚えておくと、見分けがつきます。
| 種類 | 例 |
|---|---|
| 寸法 | offsetWidth / offsetHeight / clientWidth / clientHeight |
| 位置 | offsetTop / offsetLeft / getBoundingClientRect() |
| スクロール | scrollTop / scrollHeight |
| 算出値 | getComputedStyle() の寸法系プロパティ |
ループの中にこれらが出てきたら、そこを見てください。 CSSの書き方の問題ではなく、CSSを読み書きする順番の問題です。 これはセレクタをいくら簡潔にしても直りません。
いまのCSSにある道具
2012年には無かったもので、速度に直接効くプロパティがあります。 MDNも挙げています。
| プロパティ | 何をするか | 使いどころ |
|---|---|---|
content-visibility | ブラウザがレイアウトとレンダリングを遅らせられる | 画面外の長いコンテンツ |
will-change | 変更が予想される要素をブラウザに知らせる | アニメーションの直前。常時付けない |
contain | 影響範囲をその要素に閉じ込める | 独立した部品 |
/* 画面外の記事カードは、入ってくるまで描かない */
.card {
content-visibility: auto;
contain-intrinsic-size: auto 300px; /* 想定の高さを伝える */
}
contain-intrinsic-size を一緒に書いてください。
高さを伝えないと、スクロールバーの長さが途中で変わり、
レイアウトのずれ(CLS)になります。速くするつもりで別の指標を悪くします。
will-change は常時付けると逆効果です。
ブラウザに「これから変わる」と予告して準備させるものなので、
ずっと予告していると、ずっと資源を抱えたままになります。
どこから手を付けるか
推測で直さないでください。 どこが遅いかは、ページによって違います。
| 症状 | 疑う場所 |
|---|---|
| 最初の描画まで真っ白 | CSSの読み込み(詳しく)。サーバーの応答も見る |
| 文字は出るが画像が遅い | LCPの要素が画像。寸法指定・形式・優先度 |
| 表示後にガタつく | CLS。画像・広告・Webフォントの読み込み |
| スクロールが重い | 長いDOM。content-visibility を検討 |
| クリックの反応が鈍い | INP。読み書きの順番を疑う(詳しく) |
| なんとなく重い | まず測る。セレクタを疑うのは最後 |
最後の行が、この記事の主題です。 「なんとなく重い」からセレクタを書き直すのは、0.1msを取りに行く作業です(表3)。 先に測って、どこで時間を使っているかを見てください。
| 手段 | 分かること |
|---|---|
| 開発者ツールの Network タブ | 何がどれだけ描画を止めているか |
| 開発者ツールの Performance タブ | スタイル計算・レイアウト・描画の内訳 |
performance.getEntriesByType('paint') | FCPの時刻 |
PerformanceObserver(largest-contentful-paint) | LCPの時刻と、その要素 |
| 実際の利用者の数値 | 75パーセンタイルで判定される(詳しく) |
4行目が効きます。LCPの「要素」が取れるので、 何を速くすればいいかが一発で決まります。 画像ならその画像、見出しならCSSの読み込み、という具合です。
AIに任せるときの注意
| 頼むこと | 向くか | 理由 |
|---|---|---|
| Networkタブの一覧を貼って「何が描画を止めている?」 | 向く | ブロッキングの判定は機械的 |
「media 付きに分割する案を出して」 | 向く | 条件の切り出しは読めば分かる |
「content-visibility を付けるならどこ?」 | 向く | 画面外になりやすい部品は構造から判断できる |
| 「CSSを高速化して」 | 向かない | セレクタの書き換えが返る。0.1msの作業 |
| 「Core Web Vitalsを説明して」 | 向かない | FIDが出てきたら2024年3月より前の情報 |
| 実際の数値の判定 | 向かない | 75パーセンタイルの実測値は自分で見る |
判定の目安です。答えに「子孫セレクタを避ける」「IDセレクタを使う」が出てきたら、2010年代前半の情報です。 レンダリングブロックの話が出てくれば、現代的です。
よくある質問
子孫セレクタは避けるべきですか
速度のためなら、避ける必要はありません。 条件を揃えて測ったところ、4段の子孫セレクタと単純なクラスセレクタの差は0.1ミリ秒でした(表3)。 保守のために簡潔に書くのは、別の話として正しいです。
IDセレクタが一番速いのでは
比較の条件が揃っていません。 IDは1要素、クラスは数千要素にマッチします。 差の正体は「何要素を塗り直すか」です(表4)。
セレクタが右から左に解釈されるのは本当ですか
解釈の仕組みとしてはそのとおりです。 ただし「だから遅い」という結論は、実測では出ませんでした(表3)。
では何をすればいいですか
CSSの読み込みが描画を止めている時間を減らすことです(詳しく)。 MDNもCSSの主な課題として「レンダリングブロックと再フローの最小化」を挙げています。
FIDはどうなりましたか
2024年3月12日にINPへ置き換わり、廃止されました(web.dev)。 FIDを挙げている解説は、それより前の情報です。
content-visibility を付ければ速くなりますか
画面外の長いコンテンツには効きます。ただし
contain-intrinsic-size で想定の高さを伝えてください(詳しく)。
伝えないとレイアウトのずれ(CLS)になります。
再フローとは何ですか
レイアウトの再計算です。寸法や位置を読むと、ブラウザは正しい値を返すために計算を終わらせます。 読んだ直後に書くのを繰り返すと、その回数だけ計算が走ります。 2,000要素で測ったら378ms、順番を直すと0.4msでした(表8)。
自分の環境では速いのですが
判定は75パーセンタイルで行われます(詳しく)。 平均でも最速でもなく、遅いほうの4人に1人がどうかで決まります。
@import はいまでも遅いですか
遅いです。1本目を読み終わってから2本目を取りに行くので、待ち時間が足し算になります(表7)。
<link> を並べれば同時に取得できます。
まとめ
- セレクタの最適化は、条件を揃えて測ると0.1msの差だった。12,008要素のDOMで2,000要素を塗り直しての値。
- 「IDが最速」は比較の条件が揃っていない。差の正体は、塗り直す要素の数。
- MDNもセレクタの簡略化は勧めているが、理由に「メンテナンス」を並べている。
- MDNが主な課題として挙げるのは「レンダリングブロックと再フローの最小化」。そちらが本命。
- CSSは読み終わるまで描画を止める。サイズと本数がそのまま最初の描画の遅さになる。
mediaを付ければ、条件外ではブロックしない(MDNが挙げている手)。@importは待ち時間が足し算になる。これは昔から変わらず有効な指摘。- 指標は LCP 2.5秒 / INP 200ms / CLS 0.1。判定は75パーセンタイル。
- FIDは2024年3月12日にINPへ置き換わって廃止された。FIDと書いてある記事は、それ以前の情報。
content-visibilityにはcontain-intrinsic-sizeを添える。さもないとCLSが悪化する。- MDNが挙げるもう一方は再フロー。読み書きを交互にすると2,000要素で378ms、順番を直すと0.4ms。約945倍の差。
offsetHeightやgetBoundingClientRect()をループ内で読んでいたら、そこが原因。- 推測で直さない。LCPの「要素」まで測れば、何を速くすべきか一発で決まる。
サーバーの応答が遅いのではと思ったら レンタルサーバーおすすめ8選、 リダイレクトで往復が増える話は wwwあり・なしを統一する、 CSSだけでできることは URLのアンカーで処理を分ける にも書きました。
セレクタの測定値は、2026年10月25日に Chrome 152.0.7977.167 で、 12,008要素のDOMに対して51回測った中央値です。 再フローの測定(378ms / 0.4ms)は同じ日・同じブラウザで2,000要素に対して11回測った中央値です。 DOMの規模・構造・ブラウザが変われば結果は変わります。 このサイトの数値はローカルサーバーでの測定なので、絶対値は参考になりません。 自分のページで測ってから手を入れてください。