YouTube を遅らせたら、サイトが速くなった — LCP 21秒を9秒に削った話
今回の登場人物
Pop(ポップ)
AI パートナー / 技術顧問
TAP the POP の技術顧問。音楽メディアの巨大なコンテンツを支えるフロントエンドとインフラを担当。「サーバーは速い。遅いのはフロントだ」を証明した男。
スマホで開いたら、21秒かかった
TAP the POP は、音楽の歴史と物語を届けるメディアだ。記事数は数万本に及ぶ。そしてその記事の多くに、YouTube の動画が埋め込まれている。
モバイルで LCP(Largest Contentful Paint)を計測したら、21,352ms という数字が出た。21秒。画面に「最も大きなコンテンツ」が現れるまで、21秒かかっていた。
ポップが最初にログを見た時の言葉が残っている。
「TTFBは最初から最後まで2〜3ms。サーバーは一貫して速かった。遅かったのはフロントエンドだった」
サーバーは悪くない。問題はブラウザに渡した後に起きていた。
YouTube が全部同時に起動していた
原因を特定するのに時間はかからなかった。記事に埋め込まれた YouTube の <iframe> が、ページ読み込みと同時に全部起動していた。
YouTube の <iframe> は1つでも重い。1本の動画を表示するだけで、Google のスクリプト、サムネイル画像、プレーヤーのリソースが大量に読み込まれる。それが記事1本に複数埋まっていれば、ページが開いた瞬間にネットワークが飽和する。
解決策はシンプルだった——「まだ見ていない動画のリソースを、今すぐ読み込む必要はない」。
facade 方式の遅延読込:v1.0〜v1.4 の格闘
ポップが実装したのは facade(ファサード)方式 の遅延読込だ。サムネイル画像だけ先に表示しておき、ユーザーがクリックした時に初めて本物の <iframe> を読み込む。動画のリソースを「本当に必要になった時」まで後回しにする。
ただし、v1.0 で「よし」と思ってから、4回書き直すことになった。
v1.0: まず動いた。LCP 半分。「よし」と思った。
facade 方式を実装した翌朝、LCP は半分になっていた。21,352ms → 9,943ms。「よし」と思った。
v1.1: 「動画の前後に不自然な空白が。全ての記事です。」
翌朝、顧客から連絡が来た。動画の前後に不自然な空白が出ている、全ての記事で。ポップは空の <p> タグの問題と判断して修正した。「直った」と思った。
v1.2: 「まだ直っていない。」緊急。
その翌朝、また連絡が来た。「まだ直っていない。」v1.1 は空 <p> だけを見ていた。<br> タグが残っていた。根を取れていなかった。
v1.3: ナミオさんがCSSを指差した。
「.movie の padding-bottom: 50% がそのまま空白になってる」。ナミオさんが実画面を見て、CSSのクラスを指差した。テーマの CSS には触れず、footer で !important でオーバーライドする。これが本当の根治だった。
v1.4: 「クリックしたら動画が消える」
ナミオさんが実機で報告した。クリックしたら動画が消える。3回直してもまだバグがあった。実機テストで確認し、完全クローズ。
ポップはこう言った。
「合計4回書き直した。俺が1回実装して、バグを3回見逃した。その3回を認めながら詰めた」
最終結果
| 指標 | 変更前 | 変更後 | 差分 |
|---|---|---|---|
| LCP | 21,352ms | 9,943ms | -52% |
| INP | 275ms | 59ms | -79%(Poor→Good) |
| FCP | 12,710ms | 5,647ms | -56% |
| Score | 50 | 61 | +11点 |
| TTFB | 3ms | 2ms | 変わらず |
TTFB は最初から最後まで変わらなかった。サーバーは一度も悪くなかった。
【技術コラム】YouTube facade 遅延読込を自分のサイトに実装する
YouTube の <iframe> は、埋め込んだ瞬間から重い。1つの動画でも、Google のトラッキングスクリプト・プレーヤーJS・サムネイルが読み込まれる。記事に複数埋まっていれば、LCP は確実に悪化する。
facade 方式の核心は「クリックされるまで iframe を生成しない」こと。代わりにサムネイル画像を置き、クリックで初めて本物と差し替える。
基本実装(WordPress / PHP 対応)
<!-- facade: サムネイルを先に置く -->
<div class="yt-facade" data-videoid="dQw4w9WgXcQ"
style="position:relative; padding-bottom:56.25%; cursor:pointer; background:#000;">
<img
src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"
alt="動画サムネイル"
style="width:100%; height:100%; object-fit:cover; position:absolute;"
loading="lazy">
<div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:68px; height:48px; background:rgba(255,0,0,0.9); border-radius:8px;">
<!-- 再生ボタンの三角形 -->
<svg viewBox="0 0 68 48" style="width:100%;height:100%;">
<polygon points="26,16 48,24 26,32" fill="white"/>
</svg>
</div>
</div>
<script>
document.querySelectorAll('.yt-facade').forEach(function(el) {
el.addEventListener('click', function() {
var id = el.dataset.videoid;
var iframe = document.createElement('iframe');
iframe.src = 'https://www.youtube.com/embed/' + id + '?autoplay=1';
iframe.width = '100%';
iframe.height = '100%';
iframe.style.position = 'absolute';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.allow = 'autoplay; encrypted-media';
iframe.allowFullscreen = true;
el.innerHTML = '';
el.appendChild(iframe);
});
});
</script>
WordPress の場合:投稿本文を自動変換する
WordPress の the_content フィルターで、既存の iframe を facade に一括変換できる。
// functions.php に追加
add_filter('the_content', 'replace_youtube_with_facade');
function replace_youtube_with_facade($content) {
// YouTube の iframe を正規表現でキャプチャ
$pattern = '/<iframe[^>]*src=["\']https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]+)[^"\']*["\'][^>]*><\/iframe>/i';
return preg_replace_callback($pattern, function($m) {
$id = $m[1];
$thumb = "https://i.ytimg.com/vi/{$id}/hqdefault.jpg";
return '<div class="yt-facade" data-videoid="' . esc_attr($id) . '"
style="position:relative;padding-bottom:56.25%;cursor:pointer;background:#000;">
<img src="' . esc_url($thumb) . '" loading="lazy"
style="width:100%;height:100%;object-fit:cover;position:absolute;">
<svg viewBox="0 0 68 48" style="position:absolute;top:50%;left:50%;
width:68px;transform:translate(-50%,-50%);">
<rect width="68" height="48" rx="8" fill="rgba(255,0,0,0.9)"/>
<polygon points="26,14 48,24 26,34" fill="white"/>
</svg>
</div>';
}, $content);
}
v1.3 の教訓:CSS の padding-bottom を忘れるな
facade 方式で空白が残る場合、大抵は既存のラッパー CSS が原因だ。
/* 既存テーマが持っていた問題のスタイル */
.movie { padding-bottom: 50%; } /* iframe がないと空白になる */
/* footer.php や custom CSS に追加(!important で上書き) */
.yt-facade ~ .movie,
.movie:has(.yt-facade) {
padding-bottom: 0 !important;
}
テーマの CSS を直接触らず、追加 CSS で上書きする。これがポップの v1.3 の判断だった。テーマのアップデートで CSS が戻っても、カスタム CSS は生き残る。
実装前後の計測方法
# PageSpeed Insights(モバイル)で計測
curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://www.tapthepop.net/&strategy=mobile" \
| python3 -c "import sys,json; d=json.load(sys.stdin)['lighthouseResult']['audits']; \
print('LCP:', d['largest-contentful-paint']['displayValue']); \
print('INP:', d.get('experimental-interaction-to-next-paint',{}).get('displayValue','N/A'))"
facade を入れる前後で計測して数字を残す。ポップが v1.0 の直後に「LCP 半分」と確信できたのは、変更前の数字を持っていたからだ。計測は始める前に取る。