はじめに
この記事ではIntersection Observer APIを用いてloading="lazy"
のようにビューポートに近づいた時に読み込みを開始するようなコンポーネントをReactで作成する方法を紹介します。
loading="lazy"
img
やiframe
はloading
属性にlazy
を加えることで、視覚ビューポートの範囲内に入るまでコンテンツの読み込みを延期するようになります(ブラウザによっては視覚ビューポートに近づいたタイミングで読み込みが完了するように最適化されています)。
<img src="sample.webp" alt="代替テキスト" width="25" height="25" loading="lazy" />
<iframe src="sample.html" title="タイトル" loading="lazy" />
このようにすることで、ページの読み込み時に読み込むコンテンツの総量が必要最低限まで減るのでパフォーマンスに大きく寄与します(画像はほとんどのウェブサイトで最も多いアセットで多くの帯域を占有すると言われています)。
この設定はどのような場合でもパフォーマンス上に利点があるわけではありません。
初期描画の段階で表示されるコンテンツで扱う場合は遅延読み込みさせることでレイアウトが完了されるまで読み込みが遅延するため、通常のPreload Scannerで読み込む場合に比べて幾分か損をします。
また、画像に対してwidth
とheight
を指定しなかった場合はレイアウト時にコンテンツの計算を0×0として行うので、読み込み後にレイアウトシフトが発生する可能性があります。
Intersection Observer APIで実装する
loading="lazy"
は画像やiframeの読み込み最適化にあたって便利な機能であることがわかりました。loading="lazy"
が実装される前はIntersection Observer APIやscroll
などのイベントハンドラを用いて機能を組み込んで実装されていました。
これから、Intersection Observer APIを用いて画像の遅延読み込みを行うReactコンポーネントをいくつかのパートに分けて実装します。
Intersection Observer API
まずはIntersection Observer APIについておさらいをしていきます。
Intersection Observer APIは、特定の要素やレイアウトビューポートが他の特定の要素と交差したことを監視するためのAPIです。今回実装する遅延読み込みの他に、無限ローディングなど様々な場面で活用されます。
利用を開始するにはまずIntersectionObserver
インスタンスを作成します。この時、コールバック関数とオプションを引数として渡します。コールバック関数は監視対象が交差した時に発火する処理を、オプションは監視に対する条件を渡します。
コールバック関数は引数にentries
とobserver
が渡されます。entries
はIntersectionObserverEntry
(監視対象の交差状態)の配列です。observer
はコールバックが呼び出されるIntersectionObserver
自身です。
オプションはオブジェクトを渡します。オブジェクトはroot
・rootMargin
・threshold
をキーとして持ちます。
root
は監視対象となるベースのElement
・Document
オブジェクトです。
rootMargin
監視対象のベースとなる要素のマージンを設定します。要素がビューポートに完全に入る前に交差させるように条件付けを行えます。
threshold
は0.0~1.0の値を取り監視対象の要素が交差したみなすための比率を表します。0.5の場合は監視対象の要素のうち半分の面積がベースとなる要素となっていた場合に交差したとみなされます。
オプションを渡さなかった場合は、デフォルト値が適用されます。デフォルトはレイアウトビューポートを基準に余白なしに1pxでも交差したらコールバック関数発火するように設定されます。
このようにして作成されたインスタンスはobserve
メソッドで監視対象の要素を追加、disconnect
で監視の停止を行います(メソッドは他にもありますが、ここでは紹介を省略します)。
// インスタンスの作成
const observer = new IntersectionObserve(callback, options);
// 監視要素の追加
observer.observe(element1);
observer.observe(element2);
// 全ての監視を停止する
observer.disconnect();
画像のコンポーネント準備
次に、遅延読み込みを行わない画像のコンポーネントを作成します。
type ImageProps = {
src: string;
alt: string;
width: number;
height: number;
};
const Image: FC<ImageProps> = (props) => {
return <img {...props} />;
};
なんの変哲もないただのimg
要素のpropsを絞ったコンポーネントです。
遅延読み込みを組み込む
先ほどのコンポーネントをベースに遅延読み込みを組み込んでいきます。
まずはloading
をpropsに渡せるようにします。デフォルトは'eager'
で、'eager'
と'lazy'
を渡せるようにします。
type ImageProps = {
src: string;
alt: string;
width: number;
height: number;
loading?: 'eager' | 'lazy';
};
const Image: FC<ImageProps> = ({ loading, ...props }) => {
return <img {...props} />;
};
次に、img
を監視対象としたいのでuseRef
を用いてコンポーネントで要素を管理できるようにします。
type ImageProps = {
src: string;
alt: string;
width: number;
height: number;
loading?: 'eager' | 'lazy';
};
const Image: FC<ImageProps> = ({ loading, ...props }) => {
const imgRef = useRef<HTMLImageElement>(null);
return <img ref={imgRef} {...props} />;
};
最後に、Intersection Observer APIを使ってレイアウトビューポートとimg
要素が交差した時に読み込みを開始するようにします。
type ImageProps = {
src: string;
alt: string;
width: number;
height: number;
loading?: 'eager' | 'lazy';
};
const Image: FC<ImageProps> = ({ src, loading, ...props }) => {
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (imgRef.current === null || loading === 'eager') return;
const observer = new IntersectionObserver((entries) => {
if (entries[0] && entries[0].isIntersecting && imgRef.current) {
imgRef.current.src = src;
observer.disconnect();
}
});
observer.observe(imgRef.current);
return () => observer.disconnect();
}, [src, loading]);
return <img ref={imgRef} {...props} />;
};
ReactではReactと外部システムで接続するためにuseEffect
を用います。今回の場合はReactで制御されていないブラウザAPIを扱うのでuseEffect
を用いました。
エフェクト関数ではimgRef.current
にHTMLImageElement
が存在していて、loading
要素がlazy
の場合のみ処理を行います。
そして、IntersectionObserver
インスタンスを作成します。
const observer = new IntersectionObserver((entries) => {
if (entries[0] && entries[0].isIntersecting && imgRef.current) {
imgRef.current.src = src;
observer.disconnect();
}
});
今回はimg
に対する監視だけを行うので、entries
の最初の要素があってisIntersecting
が真だったら、img
にsrc
を登録して監視を解除してます。この処理が実施されるタイミングで画像の読み込みが開始されるというわけです。
imgRef.current
の存在チェックはスコープが異なるので再度行なっています。
インスタンスが作成されたら、observe
メソッドを呼んでimg
を監視対象にしてます。
クリーンアップ関数では前回のエフェクト関数で作成した監視を引き摺らないように初期化しています。
以上でレイアウトビューポートにimg
が現れたら画像の読み込みを開始するようなコンポーネントが作成されました。IntersectionObserver
インスタンスを生成する際にoptions
でrootMargin
を適切に設定することで現れる直前から読み込みを開始することもできるのでぜひやってみてください。
このコンポーネントは読み込んでいない時のサイズが指定したwidth
、height
になりません。そのため読み込み前後でレイアウトシフトが起こります。レイアウトシフトを考慮してimg
の大きさを明示的に指定したり、箱の中にimg
を置くようにすることをお勧めします(記事ではCSSを触りたくなかったので何もしてません)。
おわりに
loading="lazy"
のような挙動をIntersection Observer APIで実装しました。通常の利用はloading="lazy"
で十分ですが、それぞれ視覚ビューポートを元にするか、レイアウトビューポートを元にするかで挙動が変わってくるので、視覚ビューポートでは画像の読み込みがうまくいかない場合はこの方法を使ってみても良いかもしれません。