はじめに
この記事では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"で十分ですが、それぞれ視覚ビューポートを元にするか、レイアウトビューポートを元にするかで挙動が変わってくるので、視覚ビューポートでは画像の読み込みがうまくいかない場合はこの方法を使ってみても良いかもしれません。