React で picture source + img を扱うときに気をつけること。あるいはブラウザ毎のimg要素の微妙な違い
最近、タイトルのような 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タグ には、メディアクエリなどを記述できます。
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を参考に判断してください。
- クローラーやロボットへの考慮が必要かどうか
というわけで、2つの実装例を書いてみましたが、プロジェクトの要件や、レンダリング方式(完全なるCSRかハイブリッドか)などでも異なるので、アレンジしてください。
参考情報
react と react-dom は以下のバージョンを利用しました
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
今回、 picture source+img タグによる画像のフォールバックを行いましたが、別の方法として、サーバーサイドで行う手法もあります。HTTPリクエストヘッダの Acceptヘッダを見ることで、ブラウザが対応している画像フォーマットを判断することができるので、サーバー側で出し分けることが可能です。しかし、動的な処理となるため、(例えばCDNエッジ処理などで)計算リソースが必要だし、キャッシュ戦略の面で考慮が必要になります。
今回の件の詳しい説明や解決方法は、以下の react の issue が参考になりました。
Vue でも発生するらしいです。現在のところチケットは閉じています。
将来的にどうなるかわかりません。Reactかブラウザどちらかの歩み寄り(最適化)があって状況が変わるかもしれません。
ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion