Core Web VitalsのCLSが広告の枠で悪くなる原因と直し方

​​Core Web Vitals​CSS,JavaScript,Lighthouse,Playwright,web-vitals

記事の途中に置いた広告の枠は、広告が届いて高さが決まった瞬間に、その下の本文をまとめて押し下げます。検証したページでは、高さ 250px の広告1つで Core Web Vitals の CLS(Cumulative Layout Shift)が 0.159 になり、枠に min-height: 250px を付けておくと 0 になりました。ブログやニュースサイトに入れた広告で、読んでいる本文がずれる「レイアウトシフト」を減らし、CLS を改善したいときの枠の高さの決め方です。

補足

この環境では本物の広告は配信できないため、本物の広告の代わりに、後から高さが決まる枠で試しています。検証用のページは、小さなサーバー(localhost:8080)が1秒遅れて返す /ad.json で広告の大きさを受け取り、その大きさの iframe(ピンクの「検証用のダミーの広告」)を枠に入れます。広告の配信サービスのタグや、広告の中でさらに大きさが変わる動きは試していません。検証環境は Ubuntu 24.04、Node.js 22、Lighthouse 13.6.0、web-vitals 5.3.0 で、ブラウザは Playwright 1.63.0 に入っている Chrome for Testing 153 です。CLS は、Playwright で幅 412px・高さ 823px の画面を開いて測った値と、Lighthouse をコマンドラインから既定の設定(モバイル、同じ 412×823)で実行した値を載せています。実際の利用者のデータ(PageSpeed Insights のフィールドデータや Search Console)と、デスクトップの設定は試していません。

広告が入ると、下の本文がまとめて250px下がった

検証に使ったのは、見出しと8つの段落が続く記事のページです。1つめの段落の下に、中身が空の広告の枠(div.ad-slot)を置きました。ページを開いたあとで /ad.json を取りに行き、返ってきた大きさ(幅 300px・高さ 250px)の iframe を枠に入れます。

html
<style>
  …
  .ad-slot { display: flex; justify-content: center; align-items: center; background: #f1f3f4; color: #9aa5ab; font-size: 12px; }
  .ad-slot iframe { border: 0; display: block; }
</style>
…
<h1>今日の記事</h1>
<p>本文の段落です(1)。…</p>
<div class="ad-slot"></div>
<p>本文の段落です(2)。…</p>
…(段落は全部で8つ)
<script>
// 広告の代わり:1秒後に返る /ad.json で大きさを受け取り、その大きさの iframe を枠に入れる
fetch('/ad.json?h=250').then((r) => r.json()).then((ad) => {
  const slot = document.querySelector('.ad-slot');
  if (!ad.filled) { slot.textContent = '広告'; return; }
  const f = document.createElement('iframe');
  f.width = ad.width; f.height = ad.height; f.title = '広告';
  f.srcdoc = '…検証用のダミーの広告…';
  slot.append(f);
});
</script>

測定には Playwright を使いました。スクリプト(measure.cjs)はページを開いて3秒待ち、ブラウザに残った layout-shift の記録と、web-vitals の onCLS の値を表示します。枠のすぐ下の段落(next)の上端が、開いた直後と3秒後でどこにあったかも出します。8つのページをまとめて測っており、下の出力はそのうち直す前のページ(before)の分です。以降の節でも、同じ出力から各ページの分を載せます。長い行の途中は「…」で省いています。

console
$ node measure.cjs before reserve reserve-100 reserve-250-get-100 nofill-collapse nofill-keep below-fold below-fold@scroll
before: scrollY=0 p.margin=16px next.top 294.015625 -> 544.015625, slot top=278.015625 height=0 display=flex -> top=278.015625 height=250 display=flex
  layout-shift entries: [{"t":1020,"value":0.159,"hadRecentInput":false,"sources":["P [294.015625,108.75]->[544.015625,108.75]",…]}]
  web-vitals onCLS: 0.1590 rating=needs-improvement
…

広告が届くまで、枠の高さは 0 でした。ページを開いてから約1秒後に枠が 250px になり、その下の段落の上端は 294px から 544px へ、広告の高さと同じ 250px 下がっています。レイアウトシフトは1回だけで、CLS は 0.1590 です。web.dev の CLS の解説では 0.1 以下を「良い」としており、その目安を超えたため、web-vitals の判定も needs-improvement(改善が必要)でした。

幅412pxの画面を、広告が届く前と届いた後で縦に並べた画像。上は1つめの段落のすぐ下に2つめの段落があり、上端は294px。下は2つの段落の間にピンクのダミーの広告が入り、2つめの段落の上端が544pxに下がっている
広告が届くと、枠の下の段落が 294px から 544px へ下がる

同じページを Lighthouse で測っても、CLS は 0.159 でした。下の出力は、Lighthouse が書き出した JSON から、点数・CLS・ずれた要素だけを summarize.mjs で取り出したものです。

console
$ npx lighthouse http://localhost:8080/before.html --only-categories=performance --output=json --output=html --output-path=./out/before --chrome-flags="--headless=new --no-sandbox" --quiet
exit: 0
lighthouseVersion: 13.6.0
requestedUrl: http://localhost:8080/before.html
screenEmulation: 412x823 mobile=true
performance score: 93
CLS: 0.159 (numericValue 0.1590, score 0.73)
layout-shifts: score=0 title="Avoid large layout shifts"
  - body > main > p  score=0.1590
cumulative-layout-shift weight: 25

「Avoid large layout shifts」の項目に、ずれた要素として挙がったのは body > main > p、つまり押し下げられた本文の段落です。広告の枠そのものは、上端の位置が 278px のまま変わっていないので、ずれた要素には入りません。

広告の枠は、中身が届くまで高さが0になる

ずれの原因は、空の div には高さがないことです。ブラウザは、広告の大きさを、届くまで知ることができません。最初の表示では枠を高さ 0 で描き、広告が入った時点で枠を広げ、その下にある要素をすべて動かします。

web.dev の CLS を改善する方法の解説でも、広告はウェブ上のレイアウトシフトの大きな原因の一つとして挙げられています。広告ネットワークや、広告を載せるサイトの運営者(パブリッシャー)は、大きさの違う広告を出せるようにしていることが多く、それが読んでいる本文を押し下げることがある、と書かれています。同じ解説には、空の要素の既定の大きさは 0px で、CLS がいちばん大きくなるとも書かれています。

直し方:枠の高さを min-height で先に空けておく

広告のように後から入る内容は、最初の表示のときから場所を取っておけば、届いても周りを動かしません。web.dev の解説でも、その方法として min-height が挙がっています。広告の枠に、CSS を1行足しました。HTML と JavaScript は直す前のままです。

css
.ad-slot { min-height: 250px; }
console
reserve: scrollY=0 p.margin=16px next.top 544.015625 -> 544.015625, slot top=278.015625 height=250 display=flex -> top=278.015625 height=250 display=flex
  layout-shift entries: []
  web-vitals onCLS: 0.0000 rating=good

枠は開いた直後から 250px あり、下の段落は最初から 544px の位置にあります。広告が届いても動かず、レイアウトシフトの記録は1件もありませんでした。Lighthouse でも CLS は 0 で、パフォーマンスの点数は 93 から 100 になりました。

console
$ npx lighthouse http://localhost:8080/reserve.html --only-categories=performance --output=json --output=html --output-path=./out/reserve --chrome-flags="--headless=new --no-sandbox" --quiet
exit: 0
lighthouseVersion: 13.6.0
requestedUrl: http://localhost:8080/reserve.html
screenEmulation: 412x823 mobile=true
performance score: 100
CLS: 0 (numericValue 0.0000, score 1)
layout-shifts: score=null title="Avoid large layout shifts"
cumulative-layout-shift weight: 25
幅412pxの画面を、広告が届く前と届いた後で縦に並べた画像。上は1つめの段落の下に、高さ250pxの灰色の空いた枠があり、2つめの段落の上端は544px。下はその枠にピンクのダミーの広告が入り、2つめの段落の上端は544pxのまま
枠の高さを先に空けておくと、広告が届いても段落は動かない

広告の大きさが決まっていないときの空け方

広告の高さがいつも 250px とは限りません。空けた高さと、届いた広告の高さが違う場合を2つ試しました。

1つめは、小さめの 100px だけを空けておき、250px の広告が届いた場合です。足りない 150px の分だけ、段落が 394px から 544px に下がりました。

console
reserve-100: scrollY=0 p.margin=16px next.top 394.015625 -> 544.015625, slot top=278.015625 height=100 display=flex -> top=278.015625 height=250 display=flex
  layout-shift entries: [{"t":1022,"value":0.0844,"hadRecentInput":false,"sources":["P [394.015625,108.75]->[544.015625,108.75]",…]}]
  web-vitals onCLS: 0.0844 rating=good

CLS は 0.0844 で、何も空けなかったときの 0.1590 より下がり、0.1 以下の「良い」に入りました。Lighthouse でも 0.084 です。web.dev の解説にも、使われる大きさのうち最も小さいものを空けておき、それより大きい広告のずれは受け入れるという考え方が書かれています。min-height なら、大きな広告が来たときは枠が広がります。

2つめは逆に、250px を空けておき、100px の広告が届いた場合です。

console
reserve-250-get-100: scrollY=0 p.margin=16px next.top 544.015625 -> 544.015625, slot top=278.015625 height=250 display=flex -> top=278.015625 height=250 display=flex
  layout-shift entries: []
  web-vitals onCLS: 0.0000 rating=good

min-height があるので、枠は 250px のまま縮まず、CLS は 0 でした。その代わり、枠の中には広告の上と下を合わせて 150px の空いた場所が残ります。web.dev の解説では、大きいほうに合わせて空けるか、過去のデータから最もよく出る大きさに合わせるかを選び、空白が増えることとの兼ね合いを考えるよう書かれています。

広告が返ってこなかったときに枠を消すと、逆向きにずれる

広告の配信では、出す広告がなく、枠が空のままになることがあります。250px を空けておいたページで、/ad.json が「広告なし」({"filled":false})を返したときに、枠を display: none で消すようにしてみました。

js
if (!ad.filled) { slot.style.display = 'none'; return; }
console
nofill-collapse: scrollY=0 p.margin=16px next.top 544.015625 -> 278.015625, slot top=278.015625 height=250 display=flex -> top=0 height=0 display=none
  layout-shift entries: [{"t":1019,"value":0.1858,"hadRecentInput":false,"sources":["P [544.015625,108.75]->[278.015625,108.75]",…]}]
  web-vitals onCLS: 0.1858 rating=needs-improvement

段落は 544px から 278px へ、266px 上に動きました。枠の 250px に加えて、枠の上下で別々だった段落の余白(16px)が、枠がなくなって1つに重なったためです。CLS は 0.1858 で、枠を空けずに広告を入れたとき(0.1590)よりも大きくなりました。Lighthouse でも 0.186、点数は 92 です。上に動くずれも、下に動くずれと同じように CLS に数えられます。

枠を消さずに、「広告」という文字だけを入れて残すと、段落は動かず CLS は 0 でした。

js
if (!ad.filled) { slot.textContent = '広告'; return; }
console
nofill-keep: scrollY=0 p.margin=16px next.top 544.015625 -> 544.015625, slot top=278.015625 height=250 display=flex -> top=278.015625 height=250 display=flex
  layout-shift entries: []
  web-vitals onCLS: 0.0000 rating=good

web.dev の解説でも、広告が返ってこないときに空けた場所を消さず、プレースホルダーを表示するよう勧めています。

画面の外の枠でも、スクロールした人にはずれが起きる

広告の枠を、画面の外(6つめの段落の下、上端が 901px)に置いたページも測りました。スクロールせずに測ると、枠の下の段落は 250px 下がりましたが、レイアウトシフトの記録はなく、CLS は 0 でした。Lighthouse でも 0 です。

console
below-fold: scrollY=0 p.margin=16px next.top 917.765625 -> 1167.765625, slot top=901.765625 height=0 display=flex -> top=901.765625 height=250 display=flex
  layout-shift entries: []
  web-vitals onCLS: 0.0000 rating=good

web.dev の CLS の解説にあるとおり、レイアウトシフトは、画面の中に見えている要素が動いたときに記録されます。動いた段落はどれも画面の外にあったので、数えられませんでした。

同じページで、開いた直後にマウスのホイールで下へスクロールし、枠が画面に入った状態で広告が届くようにすると、結果が変わりました(ページの長さの都合で、スクロールできたのは 344px までです)。

console
below-fold@scroll: scrollY=344 p.margin=16px next.top 573.765625 -> 823.765625, slot top=557.765625 height=0 display=flex -> top=557.765625 height=250 display=flex
  layout-shift entries: [{"t":1026,"value":0.0739,"hadRecentInput":false,"sources":["P [573.765625,108.75]->[0,0]",…]}]
  web-vitals onCLS: 0.0739 rating=good

画面に見えていた段落が下に押し出され、CLS は 0.0739 になりました。記録の hadRecentInput は false です。web.dev の解説によると、この値が true になって CLS から除かれるのは、タップやクリック、キー入力のような操作の直後だけで、スクロールは含まれません。Lighthouse の既定の測定はページを開いて読み込むだけなので、画面の外の枠は 0 になります。Lighthouse で CLS が 0 と出ても、本文の中にある枠には高さを空けておきます。

直す前と後の値

検証したページの値を並べると、次のとおりです。Playwright の値は web-vitals の onCLS、Lighthouse の値はモバイルの既定の設定での結果です。スクロールしたときの値は、Playwright だけで測りました。

ページ Playwright の CLS Lighthouse の CLS Lighthouse の点数
枠に高さなし、250px の広告(直す前) 0.1590 0.159 93
250px を空けて、250px の広告 0 0 100
100px を空けて、250px の広告 0.0844 0.084 98
250px を空けて、100px の広告 0 0 100
250px を空けて、広告なしで枠を消す 0.1858 0.186 92
250px を空けて、広告なしで枠を残す 0 0 100
画面の外の枠に高さなし 0 0 100
画面の外の枠に高さなし、開いてすぐスクロール 0.0739 — —

2つの測り方の値は、表示の桁の違いを除いて一致しました。CLS がいちばん大きかったのは、空けた場所を後から消したページです。届く広告と同じか、それより大きい高さを最初から空けておき、広告が来なかったときも枠を消さずに残した3つのページでは、どれも CLS は 0 でした。

​Posted by 86