0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Scroll EventからObserverに移行する。Vanilla / Vue / React事例サンプルコード

Posted at

Observerの活用法:スクロールイベントとの比較と実装例

ウェブ開発において、ユーザーのスクロール操作に応じてヘッダーや他の要素を表示・非表示にすることは一般的なインタラクションの一つです。
しかし、スクロールイベントを直接使用すると、高頻度でイベントが発火し、パフォーマンスに悪影響を及ぼす可能性があります。

本記事では、スクロールイベントの課題と、Intersection Observer APIを活用したパフォーマンス最適化の手法について解説します。
また、バニラJavaScriptVue3Reactを使用した実装例を紹介し、それぞれのコードを詳しく説明します。

目次

  1. スクロールイベントの課題
  2. Intersection Observer APIの利点
  3. 実装例
  4. まとめ
  5. 参考資料

スクロールイベントの課題

高頻度のイベント発火

  • スクロールイベントは、ユーザーがスクロールするたびに何度も発火します
  • 1秒間に数十回から数百回のイベントが発生することもあり、メインスレッドに大きな負荷をかけます

パフォーマンスへの影響

  • イベントハンドラが頻繁に呼び出されると、CPU使用率が高くなり、ページのパフォーマンスが低下します
  • スクロールの滑らかさが失われ、ユーザーエクスペリエンスが悪化します

サンプルとして、元来良く書かれるスクロールイベントの実装例を示し、イベントログも表示します。
今回のサンプルUIは、スクロールダウン時ヘッダーが消え、スクロールアップで最上部に戻るとヘッダーが戻る挙動を想定しています。
実際にスクロールさせてみると、1スクロールごとに大量のイベントが発生している事が見て取れます。

See the Pen Untitled by Pistol (@pistol) on CodePen.

Intersection Observer APIの利点

効率的なイベント発火

  • Intersection Observerは、要素の可視状態が変化したときのみコールバックを発火します
  • 不要な計算を削減し、メインスレッドの負荷を軽減します

ブラウザによる最適化

  • ブラウザが内部的に最適化を行うため、パフォーマンスが向上します
  • 非同期処理により、メインスレッドをブロックしません

ユーザーエクスペリエンスの向上

  • スクロールが滑らかになり、快適な操作性を提供できます

スクロールイベントとの比較

特性 IntersectionObserver スクロールイベント
イベント発火頻度 必要なタイミングのみ スクロール時に常時発火(フレームごと)
負荷 監視対象の数に比例(ブラウザ最適化あり) イベント回数に比例(最適化なし)
柔軟性 特定の要素の可視状態を監視するのに適している スクロール全般の動作には便利
最適化の必要性 少ない(ただし対象要素が多い場合は注意) スロットリングやデバウンスが必須

その他スロットリングやデバウンスなど、イベント発生回数を抑える手法も良く使われますが、今回はObserverに集中して記述します

では早速ですがサンプルコードを元に挙動を確認してみましょう

実装例

3.1 バニラJavaScriptによる実装

CodePen

See the Pen Vue Scroll Observe by Pistol (@pistol) on CodePen.

右下にDebug表示を設置しており、イベント発火のポイントがリアルタイムに確認出来ると思います

サンプルコード / Vanilla

<!-- ヘッダー -->
<header class="header" id="header">
  <div class="header-content">
    <div class="header-logo">Logo</div>
    <nav class="header-nav">
      <a href="#" class="nav-link">Home</a>
      <a href="#" class="nav-link">About</a>
      <a href="#" class="nav-link">Contact</a>
    </nav>
  </div>
</header>

<!-- センチネル要素 -->
<div id="sentinel" style="height: 1px;"></div>

<!-- メインコンテンツ -->
<main class="main">
  <div class="card">
    <h2>コンテンツセクション1</h2>
    <p>スクロールしてヘッダーの動作を確認してください。</p>
  </div>
  <div class="card">コンテンツカード2</div>
  <div class="card">コンテンツカード3</div>
  <div class="card">コンテンツカード4</div>
</main>

<div id="status" class="status"></div>
const header = document.getElementById("header");
const sentinel = document.getElementById("sentinel");
const status = document.getElementById("status");

// Intersection Observerのコールバック関数
const observerCallback = (entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      // ヘッダーを表示
      header.classList.remove("header--hidden");
      updateStatus("Header Shown");
    } else {
      // ヘッダーを非表示
      header.classList.add("header--hidden");
      updateStatus("Header Hidden");
    }
  }
};

// 状態を更新する関数 = 検証用
const updateStatus = (message) => {
  status.textContent = message;
  status.style.display = "block";
  // 数秒後に非表示にする(オプション)
  setTimeout(() => {
    status.style.display = "none";
  }, 3000); // 2秒後に非表示
};

// Intersection Observerのオプション
const observerOptions = {
  root: null,
  threshold: 0
};

// Intersection Observerの作成と監視開始
const observer = new IntersectionObserver(observerCallback, observerOptions);
observer.observe(sentinel);
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  min-height: 200vh;
}

.header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 60px;
  background: white;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.3s ease;
  z-index: 1000;
}

.header--hidden {
  transform: translateY(-100%);
}

.header-content {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.header-logo {
  font-size: 1.5rem;
  font-weight: bold;
}

.header-nav {
  display: flex;
  gap: 20px;
}

.nav-link {
  color: #333;
  text-decoration: none;
}

.main {
  max-width: 1200px;
  margin: 60px auto 0;
  padding: 20px;
}

.card {
  background: #f5f5f5;
  padding: 20px;
  margin-bottom: 20px;
  border-radius: 8px;
  height: 320px;
}

.status {
  position: fixed;
  bottom: 20px;
  right: 20px;
  background: rgba(0, 0, 0, 0.7);
  color: white;
  padding: 10px 20px;
  border-radius: 5px;
  font-size: 0.9rem;
  z-index: 1001;
  display: none; /* 初期状態では非表示 */
}

サンプルコード解説

  • センチネル要素
    • <div id="sentinel" style="height: 1px;"></div>はヘッダー直下に配置された目に見えない要素です
    • この要素をIntersection Observerで監視し、ビューポート内外への出入りを検知します
  • Intersection Observerの設定
    • root: nullはビューポートを基準に監視する設定です
    • threshold: 0はターゲット要素が1ピクセルでも見える・見えなくなるとコールバックを発火させます
  • コールバック関数
    • entry.isIntersectingtrueの場合、センチネルがビューポート内にあることを示します
    • ヘッダーのクラスを切り替えることで、表示・非表示を制御します
  • パフォーマンスの利点
    • スクロールイベントを使用せず、必要なタイミングでのみ処理を行うため、CPU使用率が低くなります

3.2 Vue 3による実装

CodePen

See the Pen Vue Scroll Observe by Pistol (@pistol) on CodePen.

サンプルコード / Vue

<div id="app">
  <header :class="['header', { 'header--hidden': headerHidden }]" ref="header">
    <div class="header-content">
      <div class="header-logo">Logo</div>
      <nav class="header-nav">
        <a href="#" class="nav-link">Home</a>
        <a href="#" class="nav-link">About</a>
        <a href="#" class="nav-link">Contact</a>
      </nav>
    </div>
  </header>

  <div id="sentinel" ref="sentinel" style="height: 1px;"></div>

  <div class="log-messages">
    <h4>Debug Log:</h4>
    <ul>
      <li v-for="(message, index) in logMessages" :key="index">{{ message }}</li>
    </ul>
  </div>

  <main class="main">
    <div class="card">
      <h2>Content Section 1</h2>
      <p>Scroll down to see the header behavior.</p>
    </div>
    <div class="card">Content card 2</div>
    <div class="card">Content card 3</div>
    <div class="card">Content card 4</div>
  </main>
</div>
const { createApp, ref, onMounted, onUnmounted } = Vue;

createApp({
  setup() {
    // ヘッダーの表示・非表示を管理するリアクティブな変数
    const headerHidden = ref(false);

    // DOM要素への参照を保持する
    const header = ref(null);
    const sentinel = ref(null);

    // ログメッセージを保持するリアクティブな配列
    const logMessages = ref([]);

    // Intersection Observerのインスタンス
    let observer = null;

    onMounted(() => {
      // コールバック関数
      const observerCallback = (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            // センチネルがビューポート内にある場合
            addLogMessage("Header Shown");
            headerHidden.value = false;
          } else {
            // センチネルがビューポート外にある場合
            addLogMessage("Header Hidden");
            headerHidden.value = true;
          }
        }
      };

      // Intersection Observerのオプション
      const observerOptions = {
        root: null,
        threshold: 0
      };

      // Intersection Observerの作成
      observer = new IntersectionObserver(observerCallback, observerOptions);

      // センチネル要素の監視を開始
      if (sentinel.value) {
        observer.observe(sentinel.value);
      }
    });

    // ログメッセージを追加し、3秒後に削除する関数 = 検証用
    const addLogMessage = (message) => {
      logMessages.value.push(message); // ログを追加
      setTimeout(() => {
        // 3秒後に最初のログを削除(FIFO: First In First Out)
        logMessages.value.shift();
      }, 3000); // 3000ms = 3秒
    };

    onUnmounted(() => {
      // コンポーネントがアンマウントされたときにオブザーバーを停止
      if (observer && sentinel.value) {
        observer.unobserve(sentinel.value);
        observer.disconnect();
      }
    });

    return {
      headerHidden,
      header,
      sentinel,
      logMessages
    };
  }
}).mount("#app");

CSSは同様なので、CodePenを参照ください

詳しい解説

  • Vue 3のComposition APIを使用
    • setup()関数内でリアクティブな変数や参照を定義。
  • リアクティブな変数
    • headerHiddenはヘッダーの表示状態を管理。
  • 参照の取得
    • sentinelref(null)で初期化し、テンプレート内でref="sentinel"を指定。
  • ライフサイクルフック
    • onMountedでIntersection Observerを初期化し、センチネル要素を監視開始。
    • onUnmountedで監視を停止し、リソースを解放。
  • テンプレート内のクラスバインディング
    • :class="['header', { 'header--hidden': headerHidden }]"でヘッダーの表示・非表示を制御。

3.3 Reactによる実装

CodePen

See the Pen Vue Scroll Observe by Pistol (@pistol) on CodePen.

サンプルコード / React

// React の必要なフック (useState, useRef, useEffect) をインポート
const { useState, useRef, useEffect } = React;

const App = () => {
  // ヘッダーが非表示かどうかを管理する状態変数
  const [headerHidden, setHeaderHidden] = useState(false);
  // Observe 状態のログを保持する状態変数
  const [logMessages, setLogMessages] = useState([]);
  // sentinelRef は DOM 要素を参照するための useRef。監視対象となる sentinel 要素を参照
  const sentinelRef = useRef(null);
  // ログメッセージを追加し、3秒後に削除する関数 = 検証用
  const addLogMessage = (message) => {
    // ログメッセージを配列の末尾に追加
    setLogMessages((prevMessages) => [...prevMessages, message]);
    // 3秒後に最初のメッセージを削除
    setTimeout(() => {
      setLogMessages((prevMessages) => prevMessages.slice(1));
    }, 3000); // 3000ms = 3秒
  };

  // コンポーネントがマウントされたときに実行される副作用
  useEffect(() => {
    // IntersectionObserver のコールバック関数。監視対象の交差状態が変化したときに実行
    const observerCallback = (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          // sentinel がビューポート内に入った場合
          setHeaderHidden(false); // ヘッダーを表示
          addLogMessage("Header Shown"); // ログを追加
        } else {
          // sentinel がビューポート外に出た場合
          setHeaderHidden(true); // ヘッダーを非表示
          addLogMessage("Header Hidden"); // ログを追加
        }
      });
    };

    // IntersectionObserver のオプション設定
    const observerOptions = {
      root: null, // ビューポート全体をルートに設定
      threshold: 0 // 交差が1ピクセル以上発生したときにコールバックを実行
    };

    // IntersectionObserver のインスタンスを作成
    const observer = new IntersectionObserver(
      observerCallback,
      observerOptions
    );

    // sentinel 要素が存在する場合、それを監視対象として設定
    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    // コンポーネントがアンマウントされる際にオブザーバーをクリーンアップ
    return () => {
      if (sentinelRef.current) {
        observer.unobserve(sentinelRef.current); // 監視を停止
      }
    };
  }, []); // 空の依存配列により、useEffect は一度だけ実行

  return (
    <div>
      {/* ヘッダー。headerHidden の状態によってクラスを切り替え */}
      <header className={`header ${headerHidden ? "header--hidden" : ""}`}>
        <div className="header-content">
          <div className="header-logo">Logo</div>
          <nav className="header-nav">
            <a href="#" className="nav-link">
              Home
            </a>
            <a href="#" className="nav-link">
              About
            </a>
            <a href="#" className="nav-link">
              Contact
            </a>
          </nav>
        </div>
      </header>

      {/* sentinel 要素。IntersectionObserver による監視対象 */}
      <div id="sentinel" ref={sentinelRef} style={{ height: "1px" }} />

      {/* メインコンテンツ */}
      <main className="main">
        <div className="card">
          <h2>コンテンツセクション1</h2>
          <p>スクロールしてヘッダーの動作を確認してください。</p>
        </div>
        <div className="card">コンテンツカード2</div>
        <div className="card">コンテンツカード3</div>
        <div className="card">コンテンツカード4</div>
      </main>

      {/* Observe 状態のログを右下に表示 */}
      <div className="log-messages">
        <ul>
          {/* ログメッセージをリアルタイムで表示 */}
          {logMessages.map((message, index) => (
            <li key={index}>{message}</li>
          ))}
        </ul>
      </div>
    </div>
  );
};

CSSは同様なので、CodePenを参照ください

解説

  • Reactのフックを使用
    • useStateでヘッダーの表示状態を管理
    • useRefでDOM要素への参照を取得
    • useEffectでIntersection Observerの初期化とクリーンアップを実装
  • Intersection Observerの設定
    • バニラJSやVueと同様に、センチネル要素を監視し、ヘッダーの表示・非表示を制御
  • クリーンアップ処理
    • useEffectのクリーンアップ関数内で監視を停止し、リソースを解放
  • クラス名の動的変更
    • テンプレート内でテンプレートリテラルを使用し、headerHiddenの状態に応じてクラス名を切り替え

まとめ

  • スクロールイベントの課題:高頻度のイベント発火により、パフォーマンスが低下し、ユーザーエクスペリエンスが悪化する可能性もあり、古くから使われているもののパフォーマンス課題が残ります
  • Intersection Observerの活用:要素の可視状態を効率的に監視し、必要なときにのみ処理を行うことで、パフォーマンスを向上させます
  • 実装のポイント
    • センチネル要素を使用し、特定の位置を監視
    • **entry.isIntersecting**などで要素の可視状態を判定
    • ヘッダーの表示・非表示をクラスの追加・削除で制御

参考資料


これらの実装例と解説を通じて、スクロールイベントを使用せずにパフォーマンスを最適化する方法を理解できたかと思います。
Intersection Observerを活用して、より効率的でユーザーフレンドリーなウェブアプリケーションを開発してみてください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?