😵‍💫

React で picture source + img を扱うときに気をつけること。あるいはブラウザ毎のimg要素の微妙な違い

2024/10/25に公開

最近、タイトルのような picture source + img タグのコーディングを React でやっていて、面白いなと思ったことがあったので、共有します。

一言で言えば、
Safari で picture source + img を Client Side Rendering するときは要注意ってこと!

HTMLで画像のフォールバック

まず、picture source + img タグの基本的な使い方について説明します。
HTMLで、「webpに対応しているブラウザには webp を表示、そうでなければ png にフォールバックしたい」というときは、以下のようにコーディングします。

<picture>
  <source srcset="/images/A.webp" type="image/webp" />
  <img src="/images/A.png" />
</picture>

このとき、webp 対応ブラウザならば、 A.png はダウンロードしません。
他にも、sourceタグ には、メディアクエリなどを記述できます。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/source

Reactでの問題

同じことを React で記述すると、次のようになります。
直感的には、上のHTMLと全く同じ動作になりそうに思えますが・・・
これを react-dom で Client Side Rendering すると、問題が起こります。

function MyPicture() {
  return (
    <picture>
      <source srcset="/images/A.webp" type="image/webp" />
      <img src="/images/A.png" />
    </picture>
  )
}

Safari では、png と webp どっちもダウンロードしてしまいます。
画面に表示しない無駄なフォールバック画像までダウンロードしてしまうのです。
ChromeやFirefoxでは意図通り、webpだけダウンロードしてくれます。

Safari
不本意にも、不要なフォールバック画像 A.png もダウンロード

Chrome
不要なフォールバック画像 A.png はダウンロードしない

(Next.js や Astro などのフレームワークでは、ビルド時にプリレンダリングされてしまい、完全な Client Side Rendering になりません。 以下のようなコードを一行挟むことで、擬似的に再現できます。)

if (typeof window === "undefined") return <></>;

img[src] のダウンロードタイミング

なぜブラウザごとに違いが出るのか。
img[src] はどのタイミングで解釈されてダウンロードが始まるのか。
DOM API で確かめてみます。

img要素の画像のダウンロードは window.document 配下のドキュメントツリーに加える前からダウンロードが開始されるのです。 DOM API document.createElement で要素を作って src 属性をセットするだけで、ダウンロードが行われます。
古来より、この仕組みを使って、ゲームなどのためにアセットを事前ダウンロードしたり、ダウンロード状況を管理するようなライブラリもあります。

このコードを実行するだけで、 A.png のダウンロードが開始されます。
これは、どのブラウザでも同じです。

const imgEl = document.createElement('img');
imgEl.src = '/images/A.png';

picture 要素との組み合わせ

picture 要素と組み合わせると、ブラウザごとの違いが明らかになります。
DOM API で記述します。

const pictureEl = document.createElement('picture');
const sourceEl = document.createElement('source');
sourceEl.srcset = '/images/A.webp';
sourceEl.type = 'image/webp';
const imgEl = document.createElement('img');
imgEl.src = '/images/A.png';
pictureEl.appendChild(sourceEl);
pictureEl.appendChild(imgEl);

この一連のコードを実行すると、Reactで見たように、Safari では両方の画像をダウンロードしますが、Chromeでは webp のみダウンロードします。

これまでの観察結果と、そこから導かれる推測をまとめると、以下のようになります。

ブラウザ Reactでのpicture img DOMでのpicture img img[src]解釈タイミング
Chrome,Firefox picture,sourceを解釈し
最低限の画像だけ
picture,sourceを解釈し
最低限の画像だけ
ちょっと待つ
Safari フォールバック画像まで
余計にダウンロード
フォールバック画像まで
余計にダウンロード
即座

Chrome でもアカンパターン

「ちょっと待つ」とは?
試しに、上の DOM API を使ったコードの最終行だけ変えて、別タスクで img要素を picture配下に加えるようにしてみます。

setTimeout(() => {
  pictureEl.appendChild(imgEl);
}, 1);

すると、Chrome でも、Safariと同じように、imgのダウンロードが即座に行われました。

ブラウザ ダウンロード
Chrome,Firefox フォールバック画像まで余計にダウンロード
Safari フォールバック画像まで余計にダウンロード

Safari でもOKなパターン

反対に、Safariでも、以下のコードなら、Chromeと同じように、「最低限の画像だけ」のダウンロードとなりました。

const pictureEl = document.createElement('picture');
const sourceEl = document.createElement('source');
sourceEl.srcset = '/images/A.webp';
sourceEl.type = 'image/webp';
pictureEl.appendChild(sourceEl);
const imgEl = document.createElement('img');
// CHANGE: img を pictureタグ配下に加えたのちに、src属性をセットする
pictureEl.appendChild(imgEl);
imgEl.src = '/images/A.png';
ブラウザ ダウンロード
Chrome,Firefox picture,sourceを解釈し、最低限の画像だけ
Safari picture,sourceを解釈し、最低限の画像だけ

観察結果から、少なくともReact(正確には react-dom)の内部実装はこうなっていないことがわかります。

React で、 Safari も考慮した書き方を2つ

これまでの結果を見ると、むき出しのimg要素にsrcをセットすると、ブラウザによっては、即座にダウンロードがはじまってしまうことがわかります。なお、img要素のsrcset属性も同じです。

picture,source要素を確実に解釈させたい場合は、以下のように、遅延してセットすることで可能になります。

function MyPicture() {  
  const [loaded, setLoaded] = React.useState(false);
  useEffect(() => {
    setLoaded(true);
  }, []);
  const srcValue = loaded ? "images/A.png" : undefined;

  return (
    <picture>
      <source srcset="/images/A.webp" type="image/webp" />
      <img src={srcValue} />
    </picture>
  )
}

また、もっとシンプルに、img要素に src属性を書かないことでも対応可能です。
フォールバック画像も sourceタグで記述します。
img要素には、altやサイズなど、ダウンロードにつながらない属性だけ書けます。
(弊社社員に教えてもらいました、ありがとう!!)

function MyPicture() {  
  return (
    <picture>
      <source srcset="/images/A.webp" type="image/webp" />
      <source srcset="/images/A.png" type="image/png" />
      <img />
    </picture>
  )
}

これは一見シンプルですばらしい方法ですが、いくつか注意点があります。

  • picture,sourceタグに対応していない古いブラウザでは画像が表示されない
    • caniuseを参考に判断してください。
  • クローラーやロボットへの考慮が必要かどうか

https://caniuse.com/picture

というわけで、2つの実装例を書いてみましたが、プロジェクトの要件や、レンダリング方式(完全なるCSRかハイブリッドか)などでも異なるので、アレンジしてください。

参考情報

react と react-dom は以下のバージョンを利用しました

  • "react": "^18.3.1",
  • "react-dom": "^18.3.1",

今回、 picture source+img タグによる画像のフォールバックを行いましたが、別の方法として、サーバーサイドで行う手法もあります。HTTPリクエストヘッダの Acceptヘッダを見ることで、ブラウザが対応している画像フォーマットを判断することができるので、サーバー側で出し分けることが可能です。しかし、動的な処理となるため、(例えばCDNエッジ処理などで)計算リソースが必要だし、キャッシュ戦略の面で考慮が必要になります。

今回の件の詳しい説明や解決方法は、以下の react の issue が参考になりました。

https://github.com/facebook/react/issues/22684
https://github.com/facebook/react/issues/20682

Vue でも発生するらしいです。現在のところチケットは閉じています。
将来的にどうなるかわかりません。Reactかブラウザどちらかの歩み寄り(最適化)があって状況が変わるかもしれません。

chot Inc. tech blog

Discussion