テクニック

画像 遅延読み込みでLCP 改善|loading属性とサイズ指定の実装

この記事の対象: WordPress・Shopifyのテンプレートを自分で触る制作者
読了時間: 約12分

画像が多いページで表示が重い、Lighthouseで「LCP」の項目が赤い、スクロールすると要素がガクッと動く。この3つは原因がつながっていて、テンプレートに数行足すだけで大半が解消します。特別なプラグインもライブラリも要りません。

この記事では loading と decoding の使い分け、ファーストビューの画像を遅延させない設定、width / height を書いてレイアウトのずれを防ぐ理由を、そのままコピーして動くコードで示します。WordPress(ブロックテーマ・クラシックテーマ両方)とShopify(Online Store 2.0)の具体的な設置場所、そして直したあとに「本当に効いたか」を測る手順まで通しで書きます。


前提の確認:どの環境で何が要るか

作業を始める前に、自分の環境がどれに当たるかを確認してください。ここを取り違えると、書いたコードが呼ばれないファイルに入って何も起きません。

環境 触るファイル 必要な権限
WordPress(クラシックテーマ) 子テーマの functions.php、header.php、single.php など 管理者、またはテーマファイル編集権限
WordPress(ブロックテーマ) 子テーマの functions.php、templates/*.html 同上
Shopify(Online Store 2.0) sections/*.liquid、snippets/*.liquid、layout/theme.liquid テーマの編集権限(コード編集)
静的HTML 各HTMLファイル ファイル更新権限

WordPressは有料テーマを直接編集しないでください。テーマ更新で全部消えます。子テーマを作るか、functions.php に相当する処理をプラグイン化して分離します。

Shopifyは公開中テーマを直接編集せず、複製したテーマで作業してからプレビュー確認 → 公開の順です。テーマエディタの設定値(settings_data.json)は複製時点の内容が固定されるため、作業中に公開テーマ側で設定変更があると取りこぼします。

ブラウザ対応の前提はこうです。いずれも「効かない環境では単に無視される」性質なので、条件分岐を書く必要はありません。

  • loading="lazy":主要ブラウザの現行バージョンで対応済み。非対応環境では属性が無視されるだけで、画像は普通に表示されます
  • decoding="async":同じく主要ブラウザで広く対応しています
  • fetchpriority="high":対応の広がりが最も新しい属性です。案件で切り捨てられない環境がある場合は、Can I use で fetchpriority を引いて最新の対応状況を確認してください。非対応でも無視されるだけで害はありません

バージョン番号は環境によって変わるため、記憶や記事の記載ではなく、実装時点のCan I useやMDNの表を一次情報として確認する運用にしてください。


loading と decoding の使い分け

そもそも何が起きているか

ブラウザはHTMLを読みながら、画像のURLを見つけた時点で並行してダウンロードを始めます。ページに画像が30枚あれば、画面外の29枚も含めてネットワークを取り合います。その結果、最初に見える一番大きい画像(LCP要素になりやすいもの)の到着が遅れる。これがLCPが悪化する典型的な流れです。

loading="lazy" は「この画像はビューポートに近づくまで取りに行かないでください」という指示です。画面外の画像を後回しにすることで、最初に見える画像に帯域を集中させられます。

decoding はダウンロードした画像データをピクセルに展開する(デコードする)タイミングの指示です。async を付けると、デコード待ちでレンダリングを止めなくなります。ダウンロードの優先度とは別の話なので、loading と混同しないでください。

画像1枚が表示されるまでの2段階
取得URLを見つけてダウンロードする。loadingが効くのはここ
デコードデータをピクセルに展開する。decodingが効くのはここ
描画画面に出る。ここまでの合計がLCP

この2段階が別物だと分かると、「lazyを付けたのに遅い」ケースの原因を切り分けられます。取得が遅いのか、デコードで詰まっているのかで打つ手が変わります。

属性の組み合わせ早見表

画像の位置ごとに、書くべき属性は決まっています。迷ったらこの表のとおりに書いてください。

画像の位置 loading decoding fetchpriority
ファーストビュー内でLCPになる1枚(メインビジュアル等) eager(または属性なし) sync(または属性なし) high
ファーストビュー内のロゴ・小さいアイコン eager async 指定しない
ファーストビューのすぐ下(1〜2スクロール以内) 指定しない async 指定しない
それより下、記事本文中、一覧の2行目以降 lazy async 指定しない

3列目の fetchpriority="high" は1ページに1枚だけにしてください。全部を「最優先」にすると優先度の差がなくなり、指定しないのと同じになります。

コードにするとこうです。そのまま貼って動きます。

<!-- ファーストビューのメインビジュアル:遅延させない・最優先で取りに行く -->
<img
  src="/assets/hero.webp"
  alt="工場の外観"
  width="1600"
  height="900"
  fetchpriority="high"
  decoding="sync">

<!-- ファーストビューより下:遅延させる -->
<img
  src="/assets/works-01.webp"
  alt="制作事例のスクリーンショット"
  width="800"
  height="600"
  loading="lazy"
  decoding="async">

変更する箇所は src のパス、alt の文言、width / height の数値の3つです。width / height には画像ファイルの実寸ピクセルを入れます(CSSでの表示サイズではありません)。

「ファーストビュー」をどう判定するか

一覧ページやループで出力する画像は、何枚目までがファーストビュー内かをコードで判定する必要があります。厳密にやろうとすると破綻するので、「最初のN件だけ eager、それ以降は lazy」という単純な件数判定で十分です。

Nの目安は、カラム数 × 表示行数です。3カラムのグリッドなら3〜6、2カラムなら2〜4。スマートフォンでは1カラムになって1〜2枚しか見えないため、多めに指定すると無駄が出ます。迷ったら少なめにしてください。lazyにすべきものをeagerにする害のほうが大きいためです。

判定を件数で割り切れない画面もあります。たとえば管理画面から表示件数を変えられる一覧や、ヒーローの下にお知らせバーが入ったり入らなかったりするページです。この場合は、テンプレート側で「上部に固定で入るブロックの数」を変数にしておき、その数を $eager_count に足す形にしておくと、レイアウト変更のたびに数字を書き換えずに済みます。


width と height を書く理由

CLSはこれだけで大半が消える

width / height を書かずに画像を出すと、ブラウザは画像が届くまでその領域の高さを知りません。高さ0で他の要素を積み上げ、画像が届いた瞬間に押し下げる。これがレイアウトのずれ(CLS)です。読んでいた行がスクロールで飛ぶ、押そうとしたボタンが動く、という体験になります。

現在のブラウザは width と height の比率からアスペクト比を先に計算して領域を確保します。レスポンシブでCSS側の幅が100%になっていても、比率が分かっていれば高さを予約できます。

そのためにCSS側の書き方が要ります。ここを外すと画像が歪みます。

/* 画像全般:属性の比率を保ったまま、幅だけ親に追従させる */
img {
  max-width: 100%;
  height: auto;
}

このCSSは、テーマのメインCSSファイル(WordPressなら子テーマの style.css、Shopifyなら assets/base.css を上書きするのではなく assets/custom.css を作って theme.liquid から読み込む)に置きます。

height: auto を書かないと、HTML属性の height がそのまま効いて、幅だけ縮んだ画像が縦に伸びます。ここは必ずセットです。

サイズ属性の有無で起きること

width / height を書く

  • 読み込み前に領域を確保
  • 画像到着でも要素が動かない
  • CSSの height:auto が必須

書かない

  • 高さ0で他要素を積む
  • 到着時に下へ押し下げる
  • スクロール位置が飛ぶ

図の左が目指す状態です。属性とCSSの両方が揃って初めて成立するので、片方だけ入れて確認すると「効かない」と誤診します。

トリミング表示するときの書き方

グリッドで正方形に切り揃えるようなケースでは、実寸の比率と表示比率が違います。この場合は aspect-ratio を明示し、object-fit で切り取ります。

/* カード内のサムネイル:常に4:3で表示し、はみ出しは切る */
.card-thumb {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  display: block;
}

.card-thumb は自分のテーマのクラス名に置き換えてください。aspect-ratio の 4 / 3 も、正方形にしたければ 1 / 1 に変えます。この指定があれば、HTML属性の width / height が実寸のままでもレイアウトは崩れません。

aspect-ratio は主要ブラウザの現行バージョンで使えますが、古い環境も拾う必要がある案件では、親要素に padding-top で比率を作る旧来の手法に切り替えてください。判定は案件の対応ブラウザ表と実際のアクセス解析(ブラウザ別セッションの構成)を突き合わせて決めます。


WordPressでの実装手順

前提:本文中の画像は自動でlazyになっている

WordPress 5.5以降、the_content() を通る本文中の画像には loading="lazy" が自動で付きます。さらに5.9以降では、ページ内で最初に現れる画像コンテンツは自動的に除外される挙動が入りました(先頭の画像をlazyにするとLCPが悪化するため)。

ただしこの自動判定は、テンプレート内で the_post_thumbnail() やカスタムフィールドの画像を直接出力している箇所までは面倒を見てくれません。メインビジュアルをテンプレートに直書きしているサイトほど、手当てが必要です。自分のテーマが該当するか、ブラウザの検証ツールでファーストビューの画像に loading="lazy" が付いていないか確認してください。

アイキャッチ画像に優先度を付ける

シングルページのアイキャッチをLCP要素にしている場合、テンプレートを直接書き換えます。

置き場所: wp-content/themes/子テーマ名/single.php(またはアイキャッチを出力しているテンプレート)

<?php
// アイキャッチ:ファーストビューに出るので最優先で取得させる
the_post_thumbnail( 'large', array(
    'fetchpriority' => 'high',
    'decoding'      => 'sync',
    'loading'       => 'eager',
) );
?>

the_post_thumbnail() の第2引数に渡した配列は、そのままimgタグの属性として出力されます。'large' の部分は自分のテーマで登録しているサイズ名に変えてください。

一覧ページで「最初の3件だけeager」にする

アーカイブや記事一覧のループでは、件数で判定します。

置き場所: wp-content/themes/子テーマ名/archive.php(一覧を出力しているテンプレート)

<?php
// ファーストビューに入る件数。3カラムなら3、2カラムなら2あたりから調整する
$eager_count = 3;
$index = 0;

if ( have_posts() ) :
    while ( have_posts() ) : the_post();
        $is_eager = ( $index < $eager_count );
        $index++;
        ?>
        <article class="post-card">
            <a href="<?php the_permalink(); ?>">
                <?php
                if ( has_post_thumbnail() ) {
                    the_post_thumbnail( 'medium_large', array(
                        'class'    => 'card-thumb',
                        'loading'  => $is_eager ? 'eager' : 'lazy',
                        'decoding' => $is_eager ? 'sync' : 'async',
                    ) );
                }
                ?>
            </a>
            <h2 class="post-card__title"><?php the_title(); ?></h2>
        </article>
        <?php
    endwhile;
endif;
?>

変更するのは $eager_count の数値、'medium_large' のサイズ名、post-card / card-thumb のクラス名の3つです。

本文中の画像に decoding=”async” を一括で足す

WordPressは loading は自動で付けますが、decoding の付与はバージョンによって挙動が異なります。確実に入れたい場合はフィルターで統一します。

置き場所: wp-content/themes/子テーマ名/functions.php の末尾

<?php
/**
 * 本文中の画像に decoding="async" を付与する。
 * loading="lazy" が付いている画像だけを対象にすることで、
 * 先頭画像(WordPressが自動で除外したもの)には触らない。
 */
function soreiine_add_decoding_async( $content ) {
    if ( is_admin() || is_feed() ) {
        return $content;
    }

    return preg_replace_callback(
        '/<img\b(?![^>]*\bdecoding=)([^>]*)>/i',
        function ( $matches ) {
            $attrs = $matches[1];
            // lazy が付いているものだけ async にする
            if ( stripos( $attrs, 'loading="lazy"' ) !== false ) {
                return '<img' . $attrs . ' decoding="async">';
            }
            return $matches[0];
        },
        $content
    );
}
add_filter( 'the_content', 'soreiine_add_decoding_async', 20 );

関数名の接頭辞 soreiine_ は、他のプラグインと衝突しないよう自分のサイト名に置き換えてください。add_filter の優先度 20 は、WordPress本体のlazy付与より後に走らせるための数字です。ここを小さくすると loading="lazy" がまだ付いていない状態で判定してしまい、何も置換されません。本体側の優先度は環境によって変わり得るので、置換が効かないときはまず優先度を上げて(数字を大きくして)試してください。

ブロックテーマの場合

ブロックテーマでは single.php が存在せず、テンプレートは templates/single.html にHTMLとして書かれています。ブロック側の属性を直接いじるのは壊しやすいので、出力を後から書き換えるフィルターで処理するほうが安全です。

置き場所: wp-content/themes/子テーマ名/functions.php

<?php
/**
 * ブロックテーマ:投稿アイキャッチ(core/post-featured-image)ブロックの
 * 出力に fetchpriority と decoding を足す。単一投稿ページのみ対象。
 */
function soreiine_featured_image_priority( $block_content, $block ) {
    if ( 'core/post-featured-image' !== $block['blockName'] ) {
        return $block_content;
    }
    if ( ! is_singular() ) {
        return $block_content;
    }
    // すでに指定があれば触らない
    if ( stripos( $block_content, 'fetchpriority=' ) !== false ) {
        return $block_content;
    }

    $block_content = str_ireplace( ' loading="lazy"', '', $block_content );

    return preg_replace(
        '/<img\b/i',
        '<img fetchpriority="high" decoding="sync"',
        $block_content,
        1
    );
}
add_filter( 'render_block', 'soreiine_featured_image_priority', 10, 2 );

is_singular() の条件で単一投稿ページに限定しています。一覧ページでもアイキャッチブロックは使われるため、この条件を外すと全カードが最優先になって意味がなくなります。

なお、ブロックテーマでヒーローを「カバーブロック」で組んでいる場合、画像は <img> ではなくCSSの background-image として出ることがあります。この場合は上のフィルターの対象外になるので、後述のプリロードで対処してください。ブロックの種類によって画像の出し方が変わる点は、実装前にHTMLソースで確かめておくと手戻りが減ります。

ブロックパターンでテンプレートを組んでいる場合、同じ画像の出し方をパターン側に固定しておくと、記事ごとのばらつきが減ります。編集者が触っても崩れない仕組みの作り方は、こちらでまとめています。


Shopify(Online Store 2.0)での実装手順

image_tag フィルターで属性を渡す

Shopifyのテーマでは、Liquidの image_tag フィルターに引数を渡す形が現行の書き方です。img_url を使った古い書き方が残っているテーマもありますが、新規で書くならこちらにしてください。

置き場所: sections/image-banner.liquid など、メインビジュアルを出力しているセクション

{{ section.settings.hero_image
  | image_url: width: 2000
  | image_tag:
    loading: 'eager',
    fetchpriority: 'high',
    decoding: 'sync',
    sizes: '100vw',
    widths: '600, 900, 1200, 1600, 2000',
    class: 'hero__image',
    alt: section.settings.hero_image.alt
}}

image_tag は元画像の縦横比から width / height 属性を自動で出力します。自分で計算する必要はありません。変更するのは hero_image の設定キー名と hero__image のクラス名です。widths の数値リストは、そのサイトで実際に使う表示幅に合わせて増減してください。

商品グリッドで最初の数枚だけ優先する

コレクションページの商品カードは、forloop.index で判定します。

置き場所: snippets/card-product.liquid(またはカードを描画しているスニペット)

呼び出し側でインデックスを渡します。

{%- for product in collection.products -%}
  {% render 'card-product',
    card_product: product,
    card_index: forloop.index
  %}
{%- endfor -%}

スニペット側で受け取って分岐します。

{%- comment -%} 上から3枚まではファーストビューに入る想定 {%- endcomment -%}
{%- assign eager_limit = 3 -%}
{%- if card_index <= eager_limit -%}
  {%- assign img_loading = 'eager' -%}
  {%- assign img_decoding = 'sync' -%}
{%- else -%}
  {%- assign img_loading = 'lazy' -%}
  {%- assign img_decoding = 'async' -%}
{%- endif -%}

<div class="card-product">
  <a href="{{ card_product.url }}">
    {{ card_product.featured_image
      | image_url: width: 800
      | image_tag:
        loading: img_loading,
        decoding: img_decoding,
        sizes: '(min-width: 750px) 33vw, 100vw',
        widths: '300, 500, 700, 800',
        class: 'card-product__image',
        alt: card_product.featured_image.alt
    }}
  </a>
  <p class="card-product__title">{{ card_product.title }}</p>
</div>

eager_limit の値と sizes のブレークポイントを、自分のグリッドに合わせて変えてください。sizes が実際の表示幅とずれていると、必要以上に大きい画像が配信されてかえって遅くなります。3カラムなら33vw、2カラムなら50vwが出発点です。

card_index を渡し忘れると、スニペット側で変数が空になり、比較が成立せずに全カードが lazy 側へ落ちます。エラーにはならず、画面上も一見正常に見えるため気づきにくい失敗です。呼び出し側を書き換えたら、必ずHTMLソースで先頭カードの loading の値を確認してください。

セクションの画像設定に高さを持たせる

テーマエディタで画像を差し替えられるようにしていると、比率の違う画像を入れられて崩れることがあります。セクション側でアスペクト比を固定しておくと事故が減ります。

置き場所: 該当セクションの {% stylesheet %} タグ内、または assets/custom.css

.hero__image {
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  display: block;
}

@media (max-width: 749px) {
  .hero__image {
    aspect-ratio: 4 / 5;
  }
}

スマートフォンでの比率を別に指定しているのは、横長のまま縮めると縦が潰れて主題が見えなくなるためです。4 / 5 は縦長寄りの値で、必要に応じて 1 / 1 などに変えます。

一覧まわりのカスタマイズを全体としてどう組むかは、こちらで手順を追っています。

スライダーの中の画像

スライダーやカルーセルの2枚目以降は、画面内にありながら見えていない状態です。ここを全部eagerにすると初期表示が重くなります。

  • 1枚目:eager + fetchpriority="high"
  • 2枚目以降:lazy

ただし、CSSスクロールスナップで組んだスライダーは横に並んだ状態でビューポート内に入るため、lazy でも先読みされることがあります。実測して差が出ないなら気にしなくて構いません。ライブラリを使わないスライダーの組み方は、こちらに実装があります。


測って直すための確認手順

直したあとに「速くなった気がする」で終わらせないための手順です。ここまでやって1セットだと考えてください。

手順1:LCP要素が何かを特定する

まず、そのページで一番遅く描画される大きい要素がどれかを知ります。ブラウザの開発者ツールを開き、コンソールに次のコードを貼って実行します。

// LCP要素を監視してコンソールに出す。ページを再読み込みしてから見る
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];
  console.log('LCP:', Math.round(last.startTime), 'ms');
  console.log('要素:', last.element);
  console.log('URL:', last.url || '(テキスト要素)');
}).observe({ type: 'largest-contentful-paint', buffered: true });

このコードを貼ってからページを再読み込みすると、LCP要素のDOMノードとその画像URLが出ます。ここに出た画像だけが fetchpriority="high" を付ける対象です。テキストが出た場合は画像の話ではないので、フォント読み込みやCSSの配信を疑ってください。

largest-contentful-paint の取得可否はブラウザによって差があります。取得できない環境では何も出力されずエラーも出ないため、「LCPが0」ではなく「測れていない」と判断してください。計測はChrome系で行うのが確実です。

手順2:CLSの発生元を特定する

同じくコンソールに貼ります。

// レイアウトのずれを起こした要素を記録する
let clsTotal = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue; // ユーザー操作直後は除外
    clsTotal += entry.value;
    console.log('ずれ量:', entry.value.toFixed(4), '累計:', clsTotal.toFixed(4));
    for (const source of entry.sources || []) {
      console.log('  原因要素:', source.node);
    }
  }
}).observe({ type: 'layout-shift', buffered: true });

貼ったあとページを再読み込みし、ゆっくり最後までスクロールしてください。ずれを起こした要素がそのままコンソールに出ます。画像が原因なら、その画像に width / height が付いているか、CSSで height: auto が効いているかを順に見ます。

一気にスクロールすると、遅延読み込みの画像がまとめて差し込まれて原因が混ざります。1画面ずつ止めながら下ろすと、どのブロックでずれたかが行単位で読み取れます。

手順3:遅延が効いているかを目視で確認する

開発者ツールのNetworkタブを開き、フィルターに Img を選んだ状態でページを読み込みます。

  • ページ最上部で止めた時点で、画面外の画像がリクエストされていなければ成功
  • スクロールするにつれて新しい行が増えていけば、lazy が働いています

読み込み直後に30枚全部が並んでいたら、属性が出力されていません。HTMLソースを直接見て loading="lazy" が入っているか確認してください。テンプレートを直したのに出ていない場合は、キャッシュが原因です。

このとき、Networkタブの「Disable cache」にチェックを入れ、ネットワークスロットリングを低速側(Fast 3G相当など)に落としておくと、画像の到着順が目で追える速度になります。高速回線のままだと全部が一瞬で並び、遅延が効いているかどうかを判別できません。

手順4:数値の推移を残す

直す前と直したあとで、同じページを同じ条件で測って記録します。

1ページあたりの確認の流れ
測る修正前のLCPとCLSを記録する
直す属性とCSSを入れる
再測定同じ条件で測り、差分を残す
実測で確認数日後に実ユーザーの数値を見る

4番目を飛ばさないでください。開発者ツールの数値は自分の回線と端末での値であり、実際の訪問者の環境とは違います。Search Consoleのウェブに関する主要な指標(Core Web Vitals)レポートは実ユーザーのデータを集計するため、反映まで日数がかかりますが、こちらが本番の答えです。

記録する項目は、URL・測定日時・端末とネットワークの条件・LCPの値・LCP要素・CLSの値の6つで足ります。案件ごとにスプレッドシートを1枚作り、修正のたびに行を足していく形にしておくと、「どの変更でどれだけ動いたか」が後から追えます。クライアントへの報告も、この表をそのまま見せるのが一番早いです。

計測ツールをどう組み合わせるかは、こちらで整理しています。


画像そのものを軽くする(属性の前にやること)

属性のチューニングは配信の順番を変えるだけで、ファイルが重ければ効果は限定的です。以下は属性より先に効きます。

実寸を表示幅に合わせる

3000px幅の写真を、表示幅800pxのカードに入れているケースは非常に多いです。ブラウザは縮小して表示しますが、ダウンロードするデータ量は3000px分のままです。

  • Shopify:image_url: width: 800 を指定すれば、CDN側でリサイズされた画像が配信される
  • WordPress:メディアライブラリが自動生成するサイズ(medium_large など)を使う。full を指定すると原寸が出る

高解像度ディスプレイを考慮する場合は、表示幅の2倍を上限の目安にします。表示幅800pxなら1600pxまでで、それ以上は見た目の差がほとんど出ないまま容量だけ増えます。srcset / sizes を併用していれば、ブラウザが端末に合った1本を選ぶので、最大値だけ決めておけば十分です。

形式をWebPにする

同じ見た目でJPEGより小さくなります。WebPは主要ブラウザの現行バージョンで表示できるため、現在は素の <img src="...webp"> で問題ありません。

Shopifyは対応ブラウザに自動でWebP / AVIFを配信するため、アップロードするファイル形式を気にする必要はありません。WordPressは新しめのバージョンでWebPのアップロードに対応していますが、既存画像の一括変換は自動ではないので、アップロード前に変換するかプラグインで処理します。

写真はWebP、透過が必要なロゴやアイコンはSVG、というのが基本の振り分けです。SVGはどれだけ拡大しても劣化せず、単純な図形ならWebPより軽くなります。ただしWordPressは標準ではSVGのアップロードを許可していない点に注意してください(許可する場合はサニタイズ処理が必須です)。

装飾画像はCSSに寄せる

背景の模様、区切り線、単色の図形は、画像ファイルにせずCSSのグラデーションや border で描けます。リクエストが1本減るのは、属性の最適化より確実です。


うまくいかないとき

属性を書いたのにHTMLに出ていない

出力を最後に触っている層を疑います。順番に確認してください。

  1. キャッシュ:WordPressのキャッシュプラグイン、Shopifyのテーマキャッシュ、CDN、ブラウザキャッシュ。この順で消してから再確認する
  2. 最適化プラグイン:画像最適化・遅延読み込み系のプラグインが、自前で属性を書き換えていることがあります。一時的に無効化して出力を比較する
  3. 編集ファイルが違う:親テーマを編集していて子テーマが有効になっている、あるいはその逆。テンプレート階層を確認する

WordPressで確認する簡易な方法として、テンプレートに <?php echo '<!-- ' . __FILE__ . ' -->'; ?> を仕込んでHTMLコメントに出す手があります。どのファイルが実際に呼ばれているか一目で分かります。確認が済んだら消してください。本番に残すと、サーバーの絶対パスがHTMLソースに出たままになります。

lazyを付けたらLCPが悪化した

ファーストビューの画像にまで lazy を付けています。lazyの画像は優先度が下げられてから取りに行くため、最初に見える画像に付けると逆効果です。

一括で全画像にlazyを付けるプラグインや、テンプレートで無条件にlazyを出力している箇所を探してください。前述の手順1でLCP要素を特定し、その画像だけ eager に戻します。

fetchpriority を付けても速くならない

考えられる原因は3つです。

症状 原因 対処
そもそもLCP要素が別 大きなテキストブロックやCSS背景画像がLCPになっている 手順1で要素を特定し直す
対象が背景画像 CSSの background-image には fetchpriority を書けない <img> に置き換えるか、<link rel="preload" as="image"> を使う
画像が重すぎる 優先度を上げてもダウンロード自体に時間がかかる 実寸の見直しとWebP化を先にやる

CSS背景画像をどうしても使う場合のプリロードはこう書きます。

置き場所: <head> 内(WordPressなら header.php、Shopifyなら layout/theme.liquid の </head> の直前)

<link rel="preload" as="image" href="/assets/hero-bg.webp" fetchpriority="high">

このタグはそのページで確実に使う画像にだけ書いてください。全ページ共通の <head> に入れると、使わないページでも無駄にダウンロードします。テンプレートで出し分けるか、WordPressなら is_front_page() などの条件分岐で囲みます。

スクロールしてもガタつく

width / height を入れたのに直らない場合、原因が画像ではないことがあります。

  • Webフォント:フォント読み込み完了時に文字幅が変わる。font-display: swap を使っている場合に起きやすい
  • 広告・埋め込み:iframe に高さ指定がない
  • 後から挿入される要素:JavaScriptでバナーやお知らせを後から差し込んでいる

手順2のコードで原因要素が特定できます。source.node に出た要素をそのまま見てください。後から挿入される要素が原因なら、挿入先の親要素にあらかじめ min-height を与えて場所を空けておくのが定石です。

Shopifyで image_tag が動かない

image_tag は画像オブジェクトを直接渡すのではなく、image_url フィルターを通したURLに対して使います。

{%- comment -%} 動く書き方 {%- endcomment -%}
{{ product.featured_image | image_url: width: 800 | image_tag }}

{%- comment -%} 動かない書き方 {%- endcomment -%}
{{ product.featured_image | image_tag }}

また、画像が未設定のときは何も出力されず、レイアウトが崩れることがあります。プレースホルダーを出すなら分岐を書きます。

{%- if product.featured_image != blank -%}
  {{ product.featured_image | image_url: width: 800 | image_tag: loading: 'lazy', decoding: 'async' }}
{%- else -%}
  {{ 'product-apparel-1' | placeholder_svg_tag: 'card-product__placeholder' }}
{%- endif -%}

'product-apparel-1' の部分は用途に応じた別のプレースホルダー名に変えられます。プレースホルダーはSVGで出力されるため、.card-product__placeholder 側にも aspect-ratio を効かせておかないと、画像ありのカードと高さが揃いません。


よくある質問

すべての画像に loading=”lazy” を付けてはいけませんか

ファーストビューに表示される画像には付けないでください。lazyの画像は優先度が下げられてから取得されるため、最初に見える画像に付けるとLCPが確実に悪化します。画面外の画像だけを対象にするのが正しい使い方です。

width と height はCSSで指定しているのにHTML属性も必要ですか

必要です。CSSはスタイルシートを読み込んだあとに適用されますが、HTML属性はHTMLを解析した時点で読まれます。属性があればブラウザは画像が届く前に領域を確保でき、レイアウトのずれを防げます。CSS側では height: auto を書いて、属性の値がそのまま高さにならないようにします。

fetchpriority=”high” は複数の画像に付けてもいいですか

1ページに1枚だけにしてください。優先度は相対的なもので、全部を高くすると差がなくなり、指定していない状態と変わらなくなります。付ける対象は、実測でLCP要素と判明した画像だけです。

修正したのにSearch Consoleの数値が変わりません

Core Web Vitalsのレポートは実ユーザーのデータを一定期間まとめて集計するため、反映まで日数がかかります。まずは開発者ツールで直したページのLCPとCLSが改善しているかを確認し、そのうえで期間を置いて実測値の推移を見てください。修正前後の数値を記録しておくと、判断がぶれません。

遅延読み込みのプラグインを入れれば済みませんか

本文中の画像はWordPress本体が5.5以降で自動対応しているため、プラグインを追加する必要性は下がっています。プラグインは全画像に一律でlazyを付けることがあり、ファーストビューの画像まで遅延させてLCPを悪化させるケースがあります。テンプレートに数行足すほうが、意図した挙動になります。


まとめ

やることは3つに集約されます。

  1. ファーストビューの画像1枚を特定し、fetchpriority="high" と decoding="sync" を付ける。lazyは絶対に付けない
  2. それ以外の画像に loading="lazy" と decoding="async" を付ける。一覧は件数判定で分岐する
  3. すべての画像に width / height を書き、CSSに img { max-width: 100%; height: auto; } を置く

そのうえで、記事中の PerformanceObserver のコードで修正前後を測り、数日後にSearch Consoleの実測値を見る。ここまでが1セットです。

属性の追加は数行の作業ですが、順番を間違えると逆効果になります。まずLCP要素が何かを測ってから手を入れる。この順番だけは崩さないでください。