👻

【Next.js × IntersectionObserver】画像読み込み中のスピナーを実装してみた

に公開

はじめに

「useRefやuseEffectを使って、何かReact的なUIを作りたい」と思い、
ChatGPTに相談したところ「IntersectionObserverを使って画像遅延読み込み+スピナー表示」を提案してくれました。

いろいろと調べたところ、Next.jsを使用するのであれば、Next/Imageが遅延読み込みに対応していることもあり、自力でわざわざ実装する必要性はあまりなさそうな印象でした。
しかし、IntersectionObserver を扱う練習としてとても良い題材だと思いましたので、備忘録的に記事にしてみました。

useRefとuseEffectについて簡単におさらい

https://ja.react.dev/reference/react/useRef
https://ja.react.dev/reference/react/useEffect

公式を読むのが一番わかりやすい気がしますが、自分なりに言語化してまとめてみます。

useRef

今のところ、DOM操作をするためにDOMを保持するためのもので、レンダー時の値を保持するhookだと思っています。
私はまだDOM操作でしか扱ったことはありませんが、DOM操作に限らずそのほかの方法もあるみたいです。
まだまだ利用経験が浅いですが、DOM操作で扱うことは多いようなので、基本的にはuseEffectと一緒に使うことになるのかなと思っています。
公式にはレンダー時に不要な値を参照するためのhookです、とあります。

useEffect

副作用を扱うhookです。fetchなどの非同期処理やDOM操作はすべて副作用になるので、このuseEffect内で扱うことが推奨されています。

IntersectionObserverについて

https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

要素同士(または要素とビューポート)の交差監視を可能にするAPIです。
個人では少しだけ扱ったことがあったのでなじみがあります。

環境

  • Next.js 15
  • TypeScript(最終的な成果物に使用)
  • microCMS(最終的な成果物に使用)
  • Tailwind.css

設計の方針

方針としては、以下のようにしたいと思います

  • imageの親要素、figureをuseRefで取得
  • IntersectionObserver APIでターゲットとビューポートの交差を監視する
  • useStateで交差の状態をbooleanで管理しておき、ターゲットとビューポートの交差が行われたらtrueに変更する
  • 交差がされた後、読み込んでいる時にはスピナーを、読み込みが完了したら画像を表示

サンプルを書いていく

基本構造

HTML構造は以下のようにします。

  • section直下のdivをcontainerとしてuseRefで取得します
page.tsx
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コンポーネントを作る

components/Sinner/index.tsx
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;

components/Sinner/index.module.css
/* 
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で交差を監視する

基本的な構文や具体的な仕様はこちらを参照にしてください。
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API

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をそれぞれ使用します。

page.tsx
"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というカスタムフックを作成してみたいと思います。

hook/useInview.ts
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オブジェクトを受け取れるように、ジェネリクスを使用し、呼び出すタイミングで型を決定づけられるようにしています。

ではこれを反映させます。

page.tsx
"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などにして強制的に読み込み時間を長くするとよくわかるかと思います。
https://developer.chrome.com/docs/devtools/settings/throttling?hl=ja

公開リポジトリ

こちらの記事で作成したサンプルは以下のプロジェクトで作成したものを抜粋・編集して記事にしたものでになりますので、GitHubリポジトリを載せておきます。
導入についてはREADMEを参照していただけると幸いです。

https://github.com/kaze-wind-dev/micro-motion-case

参考となるフォルダは以下の通りです。

-components/ArticleCard
-components/NewsCardList
-components/Spinner
-hook/useInview

補足
こちらのリポジトリでは、カスタムフックのuseInviewで実装しています。
microCMSと連携を行い、記事のサムネイル表示部分に使用しているため、こちらの記事の内容とは異なっているように感じるかもしれません。

最後に

Next.js×IntersectionObserverで画像読み込み中のスピナーを実装してみました。
Next.jsでIntersectionObserverの使い方を知ることができましたし、useInviewというカスタムフックを作成できたので、とても有意義だったと思います。

今回のような画像遅延読み込みのためのスピナー表示という利用ケースはあまり実用性はないかもしれませんが、目次作成やSwiperのautoplay制御に応用できそうです。
公開リポジトリにはSwiperのautoplay制御の実装も入っているので、興味があればこちらものぞいていただけると嬉しいです。

もし間違いやもっと良いやり方があれば、ぜひコメントを頂けますと幸いです!

最後までお付き合いいただきありがとうございました!

Discussion