· 11分で読了

フロントエンドで画像を扱う際に注意すべきこと

この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

最近、自分のブログの画像を見直していて、多くの記事でまだ単に <img src="..."> とだけ書いて widthheight を設定していないことに気づいた。スマホで開いたとき、画像の読み込みによってコンテンツが下にガタッとズレるのが見える。

この現象は Layout Shift と呼ばれ、画面上で予期せぬ位置のズレが発生することを指す。よくある原因は、画像自体が大きすぎるか、ネットワーク速度が追いつかず、ブラウザが画像のサイズを知らないまま先にレイアウトを組んでしまい、画像の読み込みが完了した後にレイアウトを再調整することだ。わざとLayout Shiftを起こしてユーザーに誤って広告をクリックさせようとするサイトも見たことがある。意図的かどうかは人それぞれ見解が分かれるとしても、読者の体験としては間違いなく最悪だ。

ちょうど Jake Archibald が書いたレスポンシブ画像のベストプラクティスをまとめた記事を見かけたので、ついでに僕の考えと手法を整理してみることにした。ちなみに、僕はJakeがホストを務めるHTTP203(Chrome for Developers上の番組)が大好きで、ブラウザやWebについての深い解説がたくさんある。

width と height はなぜ必ず指定すべきなのか

CSSですでに画像の幅を指定しているのだから、HTMLの widthheight は重複ではないか? しかも数値をハードコードすると柔軟性がなくなる気がして、つい書かないでおこうと考える人も多いだろう。

<img src="hero.jpg" alt="hero" />

このように書く問題点は、画像の読み込みが完了するまで、ブラウザはその画像がどれくらいの大きさなのかまったく分からないため、一旦高さ0としてレイアウトしてしまうことだ。画像のダウンロードがある程度進み、画像自体のサイズ情報が取得できてから、改めてその分のスペースを空けることになる。

その結果、画面が突然カクッと下にズレてしまう。これがいわゆる CLS(Cumulative Layout Shift) であり、Core Web VitalsにおいてSEOスコアに直結する指標の一つだ。

正しい書き方は次の通りだ:

<img src="hero.jpg" width="1600" height="900" alt="hero" />

width="1600" height="900" と記述しておけば、ブラウザはHTMLを読み込んだ時点でこの画像のアスペクト比が16:9であることを把握できる。実際にCSSで width: 100% を指定していたとしても、ブラウザはその比率に基づいて対応する高さを計算し、スペースを事前に確保しておける。画像が読み込み終わっても、画面のガタつきは発生しない。

HTMLの widthheight は実際の表示サイズではなく、ブラウザがアスペクト比を計算するためのものだ。実際の表示サイズは引き続きCSSが制御する:

img {
  width: 100%;
  height: auto;
}

2020年以降、Chrome、Firefox、Safariはいずれも widthheight 属性からCSSの aspect-ratio を自動的に推論するようになった1。実質的に裏で次のように記述してくれているようなものだ:

img[width][height] {
  aspect-ratio: attr(width) / attr(height);
}

そのため、素直に widthheight を埋めておけば、CSSで自由に拡大縮小しつつ、同時にCLSも解決できる。レイアウトが本当に動的で比率すら取得できない場合(後ほど対処法を説明する)を除き、デフォルトでこの2つの属性を付けるべきだ。(〜とはいえ、僕もまだよくサボってしまうが〜)

CSSの aspect-ratio はいつ使うべきか

画像の実際のサイズが取得できない状況もある。例えば、画像が動的なURLだったり、CMSからサイズ情報が返ってこなかったり、あるいは <div>background-image を当ててカバー画像を作ったりする場合だ。ここで登場するのがCSSの aspect-ratio だ:

.cover {
  aspect-ratio: 16 / 9;
  width: 100%;
  background-size: cover;
}

aspect-ratio は、画像の比率を固定したいときに非常に便利だ。正方形のカードを作ったり、動画のサムネイル、あるいは <iframe> を16:9に収めたりする際などに活躍する。以前は padding-top: 56.25% のようなハックを使う必要があったが、今では1行で解決する。

画像の話に戻ると、原則として:

  • 画像の実際のサイズが分かっている場合 → HTMLの widthheight に書く
  • 分からないが、表示する比率を決められる場合 → CSSの aspect-ratio を使う
  • この2つは競合しないので、併用しても問題ない

フォーマットを変える:WebP と AVIF

JPEGは1992年の仕様で、僕よりも年上だ。現代のフォーマットはこれよりもはるかに小さくできる:

  • WebP — Googleが推進するフォーマットで、2010年にリリースされた。同等の画質であればJPEGよりもファイルサイズを小さくでき2、すべての主要ブラウザでサポートされている3
  • AVIF — AV1動画コーデックをベースにした画像フォーマットで、2019年の仕様だ。写真などの画像において、中〜高画質域でJPEGよりもさらに大幅に小さくなる4。Safariはバージョン16からサポートし、ChromeとFirefoxはそれ以前から対応しており、現在では主要ブラウザを網羅している5

画像の圧縮原理を理解するには、ついでにJPEG圧縮の裏側にある秘密という記事も読んでみてほしい。

僕が現在取っているアプローチは、可能ならAVIFを使い、フォールバックとしてWebP、最終的なフォールバックとしてJPEG/PNGを用意するというものだ。実際の圧縮効果は画像の内容によって異なる。色数が少なくグラデーションの少ない画像(UIのスクリーンショットなど)はWebP/AVIFの圧縮効果が非常に高い。高周波成分(細かいディテール)が多い画像(風景写真など)では差がやや小さくなるが、それでも通常は使う価値がある。

フォーマットのフォールバックを行うには、<picture> を使用する。

picture、source、srcset の組み合わせ

<picture> の概念は至ってシンプルだ。いくつかの <source> を並べ、ブラウザは上から順に最初にサポートしているものを選択し、対応するものがなければ <img> にフォールバックする。

<picture>
  <source srcset="hero.avif" type="image/avif" />
  <source srcset="hero.webp" type="image/webp" />
  <img src="hero.jpg" width="1600" height="900" alt="hero" />
</picture>

ここまででフォーマットの問題は解決したが、サイズ(容量)の問題はまだ解決していない。スマホの画面幅が400pxしかないのに、1600px幅の画像をダウンロードするのは帯域の無駄遣いだ。そこで必要になるのが srcsetsizes だ。

srcset で複数の解像度を提供する

srcset には2つの書き方があり、使用するディスクリプタ(記述子)が異なる:

  • w ディスクリプタ:画像の実際の幅(ピクセル数)を指定する。sizes と併用することで、ブラウザが画面幅とDPRに応じて自動的に選択する
  • x ディスクリプタ:その画像が何倍のDPR向けかを指定する。表示サイズが同じで、単にRetinaディスプレイに対応したいような場面に適している

x ディスクリプタは次のように書き、比較的シンプルだ:

<img
  src="avatar.jpg"
  srcset="avatar.jpg 1x, avatar@2x.jpg 2x, avatar@3x.jpg 3x"
  width="80"
  height="80"
  alt="avatar"
/>

しかし、画面サイズによって画像の表示幅そのものが変わる場合(例えばスマホでは全幅表示、デスクトップでは固定800pxなど)、x ディスクリプタでは不十分であり、w ディスクリプタと sizes を組み合わせて使う必要がある:

<img
  src="hero-800.jpg"
  srcset="
    hero-400.jpg   400w,
    hero-800.jpg   800w,
    hero-1600.jpg 1600w
  "
  sizes="(max-width: 600px) 100vw, 800px"
  width="1600"
  height="900"
  alt="hero"
/>

sizes はブラウザに「この画像が画面上で実際にどのくらいの幅で表示されるか」を伝えるものだ。例えば上の指定は次の意味になる:

  • 画面幅 ≤ 600px の場合 → 画像は100vw(画面全体の幅)を占める
  • それ以外の場合 → 画像は固定で800px

ブラウザはこの情報を受け取った上で、デバイスのDPR(device pixel ratio、物理ピクセルとCSSピクセルの比率)を考慮し、srcsetの中から最適なバージョンを選んでダウンロードする。例えば、画面幅390pxでDPRが3のiPhoneの場合、必要な解像度は 390 × 3 = 1170px となるため、1600w のものが選ばれる。デスクトップで幅800px、DPRが2なら1600pxが必要なので同じく1600wが選ばれる。デスクトップで幅800px、DPRが1なら800wで十分だ。

フォーマットとサイズを組み合わせる

<picture>srcset を組み合わせると、次のようになる:

<picture>
  <source
    type="image/avif"
    srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1600.avif 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
  />
  <source
    type="image/webp"
    srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1600.webp 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
  />
  <img
    src="hero-800.jpg"
    srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1600.jpg 1600w"
    sizes="(max-width: 600px) 100vw, 800px"
    width="1600"
    height="900"
    alt="hero"
  />
</picture>

<source> は、同一画像の特定のフォーマットにおけるすべてのサイズを表す。そのため実際には、3フォーマット × 3サイズ = 9個のファイルを生成する必要があるかもしれない。この部分は通常、ビルドツールやCDNに任せることになる。一般的な選択肢としては、Astroの <Image>、Next.jsの next/image、あるいは画像CDNサービスなどが挙げられる。

僕は現在、Cloudflare Images を愛用している。同じ画像をAVIF/WebP/JPEGの複数バージョンとして事前にパッケージ化してアップロードしておく必要がなく、リクエストの Accept ヘッダーに基づいてブラウザが対応しているフォーマットを判別し、オンデマンドで変換して返してくれる上、CDNのキャッシュも効く。ビルド段階で各種画像を生成するのと比べ、動的な画像フォーマット変換は余計なステップを挟む必要がないのが魅力だ。

モバイル向けに別の画像へ切り替える(アートディレクション / art direction)

これまでに述べてきたのは、同じ画像の異なる解像度についてだ。しかし場合によっては、モバイル版とデスクトップ版でそもそも「別の画像」を表示させたいこともある。これがいわゆる アートディレクション(art direction) だ。

例えばデスクトップ版は16:9の横長風景写真だが、スマホにそのまま縮小して表示すると被写体が極端に小さくなり、見づらくなってしまう。こうした場合、スマホではトリミングした縦長の画像に切り替えて、被写体を大きく見せる方が良い。

<picture> のもう一つの用途がまさにこれだ。<source>media 属性を追加する:

<picture>
  <!-- 手機版:直幅裁切 -->
  <source
    media="(max-width: 600px)"
    type="image/avif"
    srcset="hero-mobile-400.avif 400w, hero-mobile-800.avif 800w"
    sizes="100vw"
  />
  <source
    media="(max-width: 600px)"
    type="image/webp"
    srcset="hero-mobile-400.webp 400w, hero-mobile-800.webp 800w"
    sizes="100vw"
  />

  <!-- 桌機版:橫幅原圖 -->
  <source
    type="image/avif"
    srcset="hero-800.avif 800w, hero-1600.avif 1600w"
    sizes="800px"
  />
  <source
    type="image/webp"
    srcset="hero-800.webp 800w, hero-1600.webp 1600w"
    sizes="800px"
  />

  <img
    src="hero-800.jpg"
    width="1600"
    height="900"
    alt="hero"
  />
</picture>

<source> は同様に上から順に最初にマッチしたものが選ばれるため、media を指定したものを上に配置する必要がある。モバイル版では手前にあるモバイル用画像が優先して適用され、デスクトップになって初めて後ろのデスクトップ版にマッチする。

1つ注意すべき細かな点として、モバイル版の画像が縦長の場合、アスペクト比が <img> に書かれた16:9とは異なる点がある。この場合はCSSの aspect-ratio を併用してコンテナの比率を一元管理するか、ブレークポイントごとにCSSで切り替える方が、<img> の固定属性に頼るよりも安定する。

object-fit で仕上げる

確保したスペースの比率と画像自体の比率が一致しない場合(例えばカードを強制的に1:1にしているが画像は16:9の場合など)、object-fit を使うことになる:

img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

object-fit<img> にも <video> にも有効だ。よく使われる2つの値:

  • cover — コンテナを満たすように表示し、画像はトリミングされる。カードのサムネイルやアバター画像などには通常これを使う
  • contain — 画像全体を表示し、コンテナの余った部分は余白になる。ロゴや商品写真など「トリミングしてはいけない」コンテンツにはこれを使う

object-position と組み合わせることで、トリミングの基準位置を調整できる(例えば object-position: top とすれば上部を基準にトリミングされ、人物のアゴが切れてしまうのを防げる)。

loading と fetchpriority

サイズやフォーマットに加えて、今や標準装備とも言える2つの属性がある:

<img
  src="hero.jpg"
  width="1600"
  height="900"
  loading="lazy"
  decoding="async"
  alt="hero"
/>
  • loading="lazy" — 画像がビューポートに入ってからダウンロードを開始する。長文記事や商品一覧、メイソンリー(ウォーターフォール)レイアウトなどで特に効果的で、初期読み込みのトラフィックを大幅に削減できる
  • decoding="async" — 画像のデコードでメインスレッドをブロックしない。デフォルトの挙動も実際にはasyncに近いが、明示することで一部のブラウザにおける同期デコードのパスを回避できる
  • fetchpriority="high" — 逆に、キーとなる画像にこれを指定することで、ブラウザに最優先でダウンロードさせることができる

ブラウザにはここ数年で数多くの画像最適化メカニズムが追加されてきた。かつてのように複雑なJavaScriptを自前で書いて最適化する必要はなくなり、手動で対応していた頃に比べてはるかに楽になった。フロントエンドがやるべきなのは、必要な属性を適切に指定し、あとはブラウザに任せることだ。

Footnotes

  1. Setting Height And Width On Images Is Important Again — Smashing Magazine

  2. WebP Compression Study — Google

  3. Can I Use — WebP

  4. AVIF for Next-Generation Image Coding — Netflix Tech Blog

  5. Can I Use — AVIF

関連記事

他のトピックを探索