Observerを活用しScroll Eventから移行する。Vanilla Vue React

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

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

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

## 目次

1. [スクロールイベントの課題](#スクロールイベントの課題)
2. [Intersection Observer APIの利点](#intersection-observer-apiの利点)
3. [実装例](#実装例)
   - 3.1 [バニラJavaScriptによる実装](#31-バニラjavascriptによる実装)
   - 3.2 [Vue 3による実装](#32-vue-3による実装)
   - 3.3 [Reactによる実装](#33-reactによる実装)
4. [まとめ](#まとめ)
5. [参考資料](#参考資料)

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

### 高頻度のイベント発火

良く使われる手法ながらも、スクロールイベントの潜在的な課題はどこにあるのでしょうか？

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

### パフォーマンスへの影響

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

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

<p class="codepen" data-height="500" data-default-tab="js,result" data-slug-hash="GRVeBZe" data-pen-title="Untitled" data-user="pistol" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/pistol/pen/GRVeBZe">
  Untitled</a> by Pistol (<a href="https://codepen.io/pistol">@pistol</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## Intersection Observer APIの利点

### 効率的なイベント発火

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

### ブラウザによる最適化

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

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

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

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

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

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

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

## 実装例

### 3.1 バニラJavaScriptによる実装

#### CodePen

<p class="codepen" data-height="500" data-default-tab="js,result" data-slug-hash="OJKqEpw" data-pen-title="Vue Scroll Observe" data-user="pistol" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/pistol/pen/OJKqEpw">
  Vue Scroll Observe</a> by Pistol (<a href="https://codepen.io/pistol">@pistol</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

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

#### サンプルコード / Vanilla

```html
<!-- ヘッダー -->
<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>
```
```js
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);
```

```css
* {
  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.isIntersecting`が`true`の場合、センチネルがビューポート内にあることを示します
  - ヘッダーのクラスを切り替えることで、表示・非表示を制御します
- **パフォーマンスの利点**：
  - スクロールイベントを使用せず、必要なタイミングでのみ処理を行うため、CPU使用率が低くなります

---

### 3.2 Vue 3による実装

#### CodePen

<p class="codepen" data-height="500" data-default-tab="js,result" data-slug-hash="OJKdmWr" data-pen-title="Vue Scroll Observe" data-user="pistol" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/pistol/pen/OJKdmWr">
  Vue Scroll Observe</a> by Pistol (<a href="https://codepen.io/pistol">@pistol</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

#### サンプルコード / Vue

```html
<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>
```

```js
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`はヘッダーの表示状態を管理。
- **参照の取得**：
  - `sentinel`を`ref(null)`で初期化し、テンプレート内で`ref="sentinel"`を指定。
- **ライフサイクルフック**：
  - `onMounted`でIntersection Observerを初期化し、センチネル要素を監視開始。
  - `onUnmounted`で監視を停止し、リソースを解放。
- **テンプレート内のクラスバインディング**：
  - `:class="['header', { 'header--hidden': headerHidden }]"`でヘッダーの表示・非表示を制御。

---

### 3.3 Reactによる実装

#### CodePen

<p class="codepen" data-height="500" data-default-tab="js,result" data-slug-hash="OJKqwLG" data-pen-title="Vue Scroll Observe" data-user="pistol" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/pistol/pen/OJKqwLG">
  Vue Scroll Observe</a> by Pistol (<a href="https://codepen.io/pistol">@pistol</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

#### サンプルコード / React

```jsx
// 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`**などで要素の可視状態を判定
  - **ヘッダーの表示・非表示**をクラスの追加・削除で制御

## 参考資料

- **MDN Web Docs - Intersection Observer API**  
  [https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API](https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API)

- **web.dev - Intersection Observer を使ったパフォーマンス最適化**  
  [https://web.dev/intersectionobserver/](https://web.dev/intersectionobserver/)

---

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