Observerの活用法:スクロールイベントとの比較と実装例
ウェブ開発において、ユーザーのスクロール操作に応じてヘッダーや他の要素を表示・非表示にすることは一般的なインタラクションの一つです。
しかし、スクロールイベントを直接使用すると、高頻度でイベントが発火し、パフォーマンスに悪影響を及ぼす可能性があります。
本記事では、スクロールイベントの課題と、Intersection Observer APIを活用したパフォーマンス最適化の手法について解説します。
また、バニラJavaScript、Vue3、Reactを使用した実装例を紹介し、それぞれのコードを詳しく説明します。
目次
- スクロールイベントの課題
- Intersection Observer APIの利点
-
実装例
- 3.1 バニラJavaScriptによる実装
- 3.2 Vue 3による実装
- 3.3 Reactによる実装
- まとめ
- 参考資料
スクロールイベントの課題
高頻度のイベント発火
- スクロールイベントは、ユーザーがスクロールするたびに何度も発火します
- 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.isIntersecting
がtrue
の場合、センチネルがビューポート内にあることを示します - ヘッダーのクラスを切り替えることで、表示・非表示を制御します
-
-
パフォーマンスの利点:
- スクロールイベントを使用せず、必要なタイミングでのみ処理を行うため、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
はヘッダーの表示状態を管理。
-
-
参照の取得:
-
sentinel
をref(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
**などで要素の可視状態を判定 - ヘッダーの表示・非表示をクラスの追加・削除で制御
参考資料
-
MDN Web Docs - Intersection Observer API
https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API -
web.dev - Intersection Observer を使ったパフォーマンス最適化
https://web.dev/intersectionobserver/
これらの実装例と解説を通じて、スクロールイベントを使用せずにパフォーマンスを最適化する方法を理解できたかと思います。
Intersection Observerを活用して、より効率的でユーザーフレンドリーなウェブアプリケーションを開発してみてください。