自社サイトのLighthouseは、Performanceが95〜100で安定していました。数字だけ見れば文句なしです。
なのに、先日スマホで自分のサイトのニュース一覧を開いたとき、記事タイトルを読もうとした瞬間に上のサムネイルが読み込まれて、本文がガクッと下にずれたんですよね。指が押そうとしたリンクが一瞬別の場所に飛んだ。あれ、これ体感めちゃくちゃ悪いな、と。
Lighthouseが満点近いから完璧だと思い込んでいたんですが、実際に触ると全然そうじゃなかった。この「数字は良いのに体感が悪い」のギャップを埋めたくて、腰を据えてCLS(Cumulative Layout Shift=累積レイアウトシフト)を潰しにいった話を書いておきます。
そもそもLighthouseの点数だけ見ていたのが間違いだった
正直なところ、最初は「Lighthouseが95点なんだから、CLSも問題ないはず」と思っていました。
ところがLighthouseのCLSは、ページを開いてから一定時間のスナップショットで測っている値です。手元のPCは回線も速いしフォントもキャッシュに載っているので、レイアウトシフトがほとんど発生しないまま計測が終わる。だから点数が良く出ていただけでした。
CLSは本来、実ユーザーがページ上で経験した「予期しないレイアウト移動」の量を積み上げた指標です。良い値は0.1以下、0.25を超えると不良、というのが一般的な線引きなんですが、この「実ユーザーの」というところがミソで、手元のラボ環境だけ見ていると取りこぼします。
なので最初にやったのは、点数を見るのをやめて、実際にどこがずれているかを目で確認することでした。ChromeのDevToolsを開いて、Renderingパネルの「Layout Shift Regions」をオンにすると、シフトが起きた瞬間に該当領域が青くフラッシュします。これでスマホエミュレーションしながらリロードすると、犯人が一目でわかりました。
うちのサイトでずれていたのは、大きく3つでした。
| ずれの犯人 | 現場で起きていたこと | やった対策 |
|---|---|---|
| サイズ未指定の画像 | サムネイルが読み込まれた瞬間、その分だけ下の本文が押し下げられる | width / height を明示して aspect-ratio で領域を先に予約 |
| 日本語Webフォントのスワップ | 代替フォント→Noto Sans JPに切り替わる瞬間に行の高さと折り返しが変わる | size-adjust などで代替フォントの寸法を本番フォントに寄せる |
| 遅延で挿入される要素 | スクロール後に現れるバナーや埋め込みが、既存コンテンツを突き飛ばす | CSSで min-height / aspect-ratio を先に確保しておく |
犯人その1: 画像のサイズを指定していなかった
一番効いたのはこれでした。
うちのニュースはAstroの <Image> を使っているので画像自体は最適化されているんですが、レイアウト上は幅だけ100%指定で、高さはブラウザが実ファイルを読むまで確定しない状態でした。つまり読み込み完了まで「高さ0」で描画されていて、画像が来た瞬間にドンと領域が広がる。
<Image> は width と height を渡すと、そのアスペクト比を使ってHTMLの属性に焼き込んでくれます。あとはCSS側で height: auto にしつつ aspect-ratio を効かせておけば、レスポンシブでも比率が保たれて、読み込み前から場所が確保されます。
---
import { Image } from 'astro:assets';
import thumb from '../assets/news-thumb.webp';
---
<!-- Before: 幅だけ100%。高さは画像が来るまで0のまま -->
<Image src={thumb} alt="記事サムネイル" class="thumb-bad" />
<!-- After: 幅・高さを渡して比率を宣言。領域を先に押さえる -->
<Image
src={thumb}
alt="記事サムネイル"
width={800}
height={450}
class="thumb"
/>
<style>
.thumb {
width: 100%;
height: auto;
aspect-ratio: 16 / 9; /* 読み込み前から16:9の箱を確保 */
}
</style>
公式ドキュメントには「<Image> は自動でサイズを最適化する」と書いてあって、確かにファイルサイズは最適化されるんですが、レイアウトの高さ予約は別の話なんですよね。ここは自分でも最初に勘違いしていました。最適化されている=ずれない、ではない。
これだけで、一覧ページのCLSが体感で半分以下になりました。
犯人その2: 良かれと思って入れたfont-display: swapが逆効果だった
ここが今回いちばん「やってみて分かった」ところです。
うちは本文にNoto Sans JPを使っています。日本語Webフォントはファイルが重いので、読み込み中に文字が見えないと離脱される。それを避けるために font-display: swap を入れていました。これで代替フォントがまず表示されて、Noto Sans JPが来たら差し替わる。表示の速さ(LCP)的にはこれで正解、という認識でした。
ところが、この「差し替わる」瞬間がCLSの原因になっていたんです。代替のゴシック体とNoto Sans JPでは1文字の幅も行の高さも微妙に違うので、切り替わった瞬間に段落全体の折り返し位置と高さが変わる。長い記事だと、本文がまるごと数ピクセル下にずれる。
ドキュメントを読むと font-display: swap はLCPには効くけどCLSとはトレードオフ、と書いてあって、なるほどこれか、と。対策としては、代替フォントの寸法を本番フォント側に寄せて、差し替え時のズレを最小化する size-adjust などのプロパティを使います。
/* 代替フォント(ローカルのゴシック)の寸法をNoto Sans JPに近づける */
@font-face {
font-family: "NotoSansJP-fallback";
src: local("Hiragino Sans"), local("Yu Gothic");
size-adjust: 108%; /* 代替の字幅を本番フォントに合わせて微調整 */
ascent-override: 88%; /* 行の上下寸法も寄せてスワップ時の高さ変動を抑える */
}
body {
/* 本番フォント → 寸法を寄せた代替 → 汎用 の順で指定 */
font-family: "Noto Sans JP", "NotoSansJP-fallback", sans-serif;
}
数値(108%とか88%)は実際に画面を見ながら合わせ込む必要があって、ここは正直、一発では決まりませんでした。ぶっちゃけ何度もリロードして目視で詰めています。swapをやめて optional にする手もあるんですが、それだと初回訪問でNoto Sans JPがそもそも当たらないことがあるので、うちは「速さは維持しつつ寸法を寄せる」方向にしました。ここは好みが分かれるところだと思います。
犯人その3: あとから挿入される要素は場所を先に確保する
3つ目は、スクロールした後に遅れて表示される類の要素です。
うちの場合は記事下部の関連コンテンツの枠と、一部ページの遅延読み込み画像がこれに当たりました。DOMに後から差し込まれるので、その分だけ下にあった要素が押し下げられる。対策はシンプルで、中身が来る前から入れ物のサイズを確保しておくことです。
/* 遅延で中身が入る枠。空でも高さを持たせて押し出しを防ぐ */
.lazy-embed {
min-height: 320px; /* だいたいの想定高さを先に確保 */
}
/* 遅延読み込みする画像も比率で箱を用意しておく */
.lazy-image {
aspect-ratio: 4 / 3;
width: 100%;
height: auto;
}
やっていることは犯人その1と同じで、「中身が確定する前に場所を押さえる」という一点に尽きます。CLS対策って結局これなんだな、と作業しながら思いました。
ラボの点数だけでなく、実測値を取りにいく
最後に、対策の効果をちゃんと確認する話です。
今思えば、最初にLighthouseの点数だけ信じていたのが遠回りの原因でした。CLSは実ユーザーが体験した値(フィールドデータ)で見ないと、手元では良く出てしまう。なので、web-vitals ライブラリを入れて、自分のブラウザで実際のCLS値をコンソールに出しながら、スクロールやフォント差し替えまで含めた「操作後」の値を確認するようにしました。
import { onCLS } from "web-vitals";
// レイアウトシフトが積み上がるたびに現在のCLS値を出す
onCLS((metric) => {
console.log("CLS:", metric.value.toFixed(3));
// 本当はここでRUM用の収集エンドポイントへ送りたい(下記の通り未完)
});
正直に書いておくと、このコードはまだ改善の余地があります。今はローカルで自分がコンソールを見て確認しているだけで、実ユーザーの値を継続的に集める仕組み(RUMの収集先や、Search Consoleのフィールドデータとの突き合わせ)まではできていません。ラボで傾向を確認したら、フィールドの数値は実ユーザーの28日分が溜まってから反映される、というタイムラグもあるので、そこを待たずに「直った気」になっているのが今の弱点です。
それでも、DevToolsのLayout Shift Regionsで青いフラッシュが出なくなって、web-vitals の出力も0.1をきちんと下回るところまでは持っていけました。数字ではなく、スマホで触ってタイトルがずれなくなったのが一番うれしかったです。
これからやりたいこと
やってみて感じたのは、CLSは派手なテクニックというより「中身が来る前に場所を確保する」を地道にやるだけ、ということでした。画像も、フォントも、遅延要素も、根っこは同じです。
次にやりたいのは、この実測を継続監視に組み込むことです。CIのLighthouseにCLSのしきい値(performance budget)を入れて、うっかりサイズ未指定の画像を足したらビルドで気づけるようにしたい。フィールドデータの収集も、軽量なビーコンだけでも仕込んでおきたいところです。
Webフォントの size-adjust の詰め方はまだ手探りなので、もっと良い合わせ込み方があったら教えてください。