【Next.js × IntersectionObserver】画像読み込み中のスピナーを実装してみた
はじめに
「useRefやuseEffectを使って、何かReact的なUIを作りたい」と思い、
ChatGPTに相談したところ「IntersectionObserverを使って画像遅延読み込み+スピナー表示」を提案してくれました。
いろいろと調べたところ、Next.jsを使用するのであれば、Next/Imageが遅延読み込みに対応していることもあり、自力でわざわざ実装する必要性はあまりなさそうな印象でした。
しかし、IntersectionObserver を扱う練習としてとても良い題材だと思いましたので、備忘録的に記事にしてみました。
useRefとuseEffectについて簡単におさらい
公式を読むのが一番わかりやすい気がしますが、自分なりに言語化してまとめてみます。
useRef
今のところ、DOM操作をするためにDOMを保持するためのもので、レンダー時の値を保持するhookだと思っています。
私はまだDOM操作でしか扱ったことはありませんが、DOM操作に限らずそのほかの方法もあるみたいです。
まだまだ利用経験が浅いですが、DOM操作で扱うことは多いようなので、基本的にはuseEffect
と一緒に使うことになるのかなと思っています。
公式にはレンダー時に不要な値を参照するためのhookです、とあります。
useEffect
副作用を扱うhookです。fetchなどの非同期処理やDOM操作はすべて副作用になるので、このuseEffect
内で扱うことが推奨されています。
IntersectionObserverについて
要素同士(または要素とビューポート)の交差監視を可能にするAPIです。
個人では少しだけ扱ったことがあったのでなじみがあります。
環境
- Next.js 15
- TypeScript(最終的な成果物に使用)
- microCMS(最終的な成果物に使用)
- Tailwind.css
設計の方針
方針としては、以下のようにしたいと思います
- imageの親要素、figureを
useRef
で取得 - IntersectionObserver APIでターゲットとビューポートの交差を監視する
- useStateで交差の状態をbooleanで管理しておき、ターゲットとビューポートの交差が行われたらtrueに変更する
- 交差がされた後、読み込んでいる時にはスピナーを、読み込みが完了したら画像を表示
サンプルを書いていく
基本構造
HTML構造は以下のようにします。
- section直下のdivをcontainerとして
useRef
で取得します
import Image from "next/image";
export default function Home() {
return (
<section className="py-[100vh]">
<div>
<figure className="mb-4 aspect-[3/2] rounded-lg overflow-hidden">
<Image
src="./images/no-image.jpg"
width={600}
height={400}
alt="noimage"
className="w-full h-full object-cover rounded-lg"
/>
</figure>
</div>
</section>
);
}
Spinnerコンポーネントを作る
import styles from "./index.module.css";
type Props = {
addClass?: string;
};
const Spinner = ({ addClass }: Props) => {
return (
<div
className={
addClass
? `${addClass} ${styles.spinner} ${styles.spinning} `
: `${styles.spinner} ${styles.spinning}`
}
></div>
);
};
export default Spinner;
/*
spinner
*/
.spinner {
position: relative;
z-index: 0;
}
.spinner::before,
.spinner::after {
content: "";
position: absolute;
visibility: visible;
opacity: 1;
transition: 1s;
}
.spinner::before {
width: 48px;
height: 48px;
border: 5px solid #fff;
border-bottom-color: transparent;
border-radius: 50%;
display: block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
inset: 0;
margin: auto;
z-index: 2;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.spinner::after {
z-index: 1;
width: 100%;
height: 100%;
background-color: var(--color-gray-400);
top: 0;
left: 0;
}
.spinner.stop::before,
.spinner.stop::after
{
visibility: hidden;
opacity: 0;
animation:none;
}
-addClass
というPropsを設けて、Taiwind.cssのクラス付与でスタイル制御ができるようにしています
-cssはindex.module.cssとして、Spinner専用のスタイルとアニメーションを当てています
IntersectionObserverで交差を監視する
基本的な構文や具体的な仕様はこちらを参照にしてください。
1例として、以下のようにして使用します。
let options = {
root: rootElement,//なければビューポート
rootMargin: "0px",//rootからのオフセット 初期値 "0px 0px 0px 0px"
threshold: 1.0,//監視要素の大きさ(面積)を1としたときの交差領域。0.0~1.0で指定し、1.0だと要素が100%root内に入ったタイミングで交差したとみなされる 初期値 0.0
};
let callback = (entries, observer) =>{
entries.forEach((entry)=>{
if(entry.isIntersecting) {
// 交差したときの処理
};
});
};
const observer = new IntersectionObserver(callback,options);
observer.observe(yourTarget);//監視を開始
では、実際にNext.jsのほうに書いてみます。
IntersectionObserver自体がreact的に副作用に該当するのでuseEffectを使用します。
また、交差したかどうかの状態をuseState、交差監視対象の要素の取得のためにuseRefをそれぞれ使用します。
"use client";
import Image from "next/image";
import { useEffect, useState, useRef } from "react";
import Spinner from "@/components/Spinner";
export default function Home() {
const [inview, setInview] = useState(false);
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const target = ref.current;
if (!target) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
observer.disconnect();
setInview(true);
}
});
observer.observe(target);
return () => observer.disconnect();
}, [ref,setInview]);
return (
<section className="py-[100vh]">
<div>
<figure
className="mb-4 aspect-[3/2] rounded-lg overflow-hidden"
ref={ref}
>
{inview ? (
<Image
src="youeImagePath" //実際の画像パスを入れてください
width={600}
height={400}
alt="noimage"
className="w-full h-full object-cover rounded-lg"
/>
) : (
<Spinner addClass="mb-4 aspect-[3/2] rounded-lg overflow-hidden" />
)}
</figure>
</div>
</section>
);
}
画像のパスやaltは適宜入れてあげてください。
実装自体はこれで完了したことになります。
最後に、カスタムフックを作成していきます。
useInviewを作ってみよう
最後に、useInviewというカスタムフックを作成してみたいと思います。
import { useEffect } from "react";
interface ObserverOptions {
threshold?: number;
rootMargin?: string;
}
interface UseInviewProps<T extends HTMLElement> {
ref: React.RefObject<T | null>;
setInview: (inview: boolean) => void;
options?: ObserverOptions;
once?: boolean;
}
const defaultOptions = {
threshold: 0,
rootMargin: "0px 0px 0px 0px"
} as ObserverOptions
export const useInview = <T extends HTMLElement>({
ref,
setInview,
options = defaultOptions,
once = true,
}: UseInviewProps<T>) => {
useEffect(() => {
const target = ref.current;
if (!target) return;
const observer = new IntersectionObserver(([entry]) => {
if (once) {
if (entry.isIntersecting) {
observer.disconnect();
setInview(true);
}
} else {
setInview(entry.isIntersecting);
}
},options);
observer.observe(target);
return () => observer.disconnect();
}, [ref, setInview, options, once]);
};
先ほどの例とは別に、once
というフラグを追加して、監視を1度で終了(disconnect
)させるのか、交差するたびに実行するのかをカスタマイズできるようにしてみました。
true
にすると監視を1度だけ実行するようになっています。
型定義についても、useRef
のrefオブジェクトを受け取れるように、ジェネリクスを使用し、呼び出すタイミングで型を決定づけられるようにしています。
ではこれを反映させます。
"use client";
import Image from "next/image";
import { useState, useRef } from "react";
import Spinner from "@/components/Spinner";
import { useInview } from "@/hook/useInview";
export default function Home() {
const [inview, setInview] = useState(false);
const ref = useRef<HTMLElement>(null);
useInview({ ref, setInview });
return (
<section className="py-[100vh]">
<div>
<figure
className="mb-4 aspect-[3/2] rounded-lg overflow-hidden"
ref={ref}
>
{inview ? (
<Image
src="/images/no-img.jpg"
width={600}
height={400}
alt="noimage"
className="w-full h-full object-cover rounded-lg"
/>
) : (
<Spinner addClass="mb-4 aspect-[3/2] rounded-lg overflow-hidden" />
)}
</figure>
</div>
</section>
);
}
こちらで完成になります。
Next.jsは表示が高速なため、スピナーが確認できないこともあるかもしれません。
もし確認できない際は、検証ツールのスロットリングを3Gなどにして強制的に読み込み時間を長くするとよくわかるかと思います。
公開リポジトリ
こちらの記事で作成したサンプルは以下のプロジェクトで作成したものを抜粋・編集して記事にしたものでになりますので、GitHubリポジトリを載せておきます。
導入についてはREADMEを参照していただけると幸いです。
参考となるフォルダは以下の通りです。
-components/ArticleCard
-components/NewsCardList
-components/Spinner
-hook/useInview
補足
こちらのリポジトリでは、カスタムフックのuseInview
で実装しています。
microCMSと連携を行い、記事のサムネイル表示部分に使用しているため、こちらの記事の内容とは異なっているように感じるかもしれません。
最後に
Next.js×IntersectionObserverで画像読み込み中のスピナーを実装してみました。
Next.jsでIntersectionObserverの使い方を知ることができましたし、useInviewというカスタムフックを作成できたので、とても有意義だったと思います。
今回のような画像遅延読み込みのためのスピナー表示という利用ケースはあまり実用性はないかもしれませんが、目次作成やSwiperのautoplay
制御に応用できそうです。
公開リポジトリにはSwiperのautoplay
制御の実装も入っているので、興味があればこちらものぞいていただけると嬉しいです。
もし間違いやもっと良いやり方があれば、ぜひコメントを頂けますと幸いです!
最後までお付き合いいただきありがとうございました!
Discussion