IntersectionObserver 実践:遅延読み込み、無限スクロールとビューポート検出
性能优化(更新: 2026年6月2日)
IntersectionObserver:高性能ビューポート検出 API
IntersectionObserver はブラウザネイティブの非同期ビューポート交差検出 API で、従来の scroll イベント監視を置き換えます:
| 比較項目 | scroll 監視 | IntersectionObserver |
|---|---|---|
| 実行スレッド | メインスレッド同期 | ブラウザ内部非同期 |
| 発火頻度 | 毎フレームの可能性 | 交差状態変化時のみ |
| 性能オーバーヘッド | 高(手動 throttle 必要) | 低(ブラウザ最適化) |
| 計算方式 | getBoundingClientRect() |
コンポジタスレッド |
| 対応状況 | 全ブラウザ | 98%+ 全球カバー |
コア API 詳解
基本的な使い方
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
console.log(entry.isIntersecting);
console.log(entry.intersectionRatio);
console.log(entry.target);
console.log(entry.boundingClientRect);
console.log(entry.rootBounds);
console.log(entry.time);
});
},
{
root: null,
rootMargin: '0px',
threshold: [0, 0.5, 1.0]
}
);
observer.observe(document.querySelector('.target'));
observer.unobserve(element);
observer.disconnect();
rootMargin:事前/遅延発火
const lazyObserver = new IntersectionObserver(callback, {
rootMargin: '200px 0px'
});
const fullObserver = new IntersectionObserver(callback, {
rootMargin: '-100px 0px'
});
threshold:発火タイミングの精密制御
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
});
const observer2 = new IntersectionObserver(callback, {
threshold: 1.0
});
実践 1:画像遅延読み込み
基本実装
<img data-src="photo.webp" alt="説明" class="lazy-img" />
class LazyImageLoader {
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!;
img.removeAttribute('data-src');
this.observer.unobserve(img);
}
});
},
{ rootMargin: '200px 0px' }
);
}
observe(container: HTMLElement) {
container.querySelectorAll<HTMLImageElement>('.lazy-img')
.forEach((img) => this.observer.observe(img));
}
}
プログレッシブロード(ぼかし → シャープ)
class ProgressiveImageLoader {
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target as HTMLImageElement;
const thumb = img.dataset.thumb!;
img.src = thumb;
img.classList.add('blurred');
const fullSrc = img.dataset.full!;
const fullImg = new Image();
fullImg.onload = () => {
img.src = fullSrc;
img.classList.remove('blurred');
};
fullImg.src = fullSrc;
this.observer.unobserve(img);
});
},
{ rootMargin: '300px 0px' }
);
}
}
.blurred {
filter: blur(20px);
transition: filter 0.3s ease;
}
実践 2:無限スクロール
class InfiniteScroll {
private observer: IntersectionObserver;
private loading = false;
private page = 1;
constructor(
private container: HTMLElement,
private loadMore: (page: number) => Promise<HTMLElement[]>
) {
const sentinel = document.createElement('div');
sentinel.className = 'scroll-sentinel';
container.appendChild(sentinel);
this.observer = new IntersectionObserver(
async (entries) => {
if (!entries[0].isIntersecting || this.loading) return;
this.loading = true;
this.page++;
const items = await this.loadMore(this.page);
items.forEach((item) => container.insertBefore(item, sentinel));
this.loading = false;
},
{ rootMargin: '500px' }
);
this.observer.observe(sentinel);
}
destroy() {
this.observer.disconnect();
}
}
実践 3:仮想スクロール
class VirtualScroller<T> {
private observer: IntersectionObserver;
private visibleRange = { start: 0, end: 0 };
private itemHeight = 48;
constructor(
private container: HTMLElement,
private items: T[],
private renderItem: (item: T, index: number) => HTMLElement
) {
this.container.style.height = `${this.items.length * this.itemHeight}px`;
this.container.style.position = 'relative';
this.setupObserver();
}
private setupObserver() {
const sentinelTop = document.createElement('div');
const sentinelBottom = document.createElement('div');
this.container.prepend(sentinelTop);
this.container.appendChild(sentinelBottom);
this.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
this.updateVisibleItems();
});
});
this.observer.observe(sentinelTop);
this.observer.observe(sentinelBottom);
}
private updateVisibleItems() {
const scrollTop = this.container.scrollTop;
const viewHeight = this.container.clientHeight;
const start = Math.floor(scrollTop / this.itemHeight);
const end = Math.min(
start + Math.ceil(viewHeight / this.itemHeight) + 2,
this.items.length
);
if (start === this.visibleRange.start && end === this.visibleRange.end) return;
this.visibleRange = { start, end };
this.render();
}
private render() {
const fragment = document.createDocumentFragment();
for (let i = this.visibleRange.start; i < this.visibleRange.end; i++) {
const el = this.renderItem(this.items[i], i);
el.style.position = 'absolute';
el.style.top = `${i * this.itemHeight}px`;
fragment.appendChild(el);
}
this.container.innerHTML = '';
this.container.appendChild(fragment);
}
}
実践 4:露出トラッキング
class ExposureTracker {
private observer: IntersectionObserver;
private tracked = new WeakSet<Element>();
constructor(private onExpose: (element: Element) => void) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
if (this.tracked.has(entry.target)) return;
this.tracked.add(entry.target);
this.onExpose(entry.target);
});
},
{ threshold: 0.5 }
);
}
track(elements: Element[]) {
elements.forEach((el) => this.observer.observe(el));
}
}
const tracker = new ExposureTracker((el) => {
const id = el.getAttribute('data-track-id');
analytics.track('element_exposed', { id });
});
性能比較
| シナリオ | scroll 監視 | IntersectionObserver | 改善 |
|---|---|---|---|
| 1000 画像遅延読み込み | ~60fps(throttle 200ms) | ~60fps(カンクなし) | スムーズさ ↑ |
| スクロールイベント/秒 | ~300 回 | ~5 回 | 60× ↓ |
| getBoundingClientRect | 1000 回/発火 | 0(ブラウザ計算) | 100% ↓ |
| メインスレッド時間 | 15-30ms/フレーム | <1ms/コールバック | 30× ↓ |
ブラウザ対応状況
| ブラウザ | バージョン | 備考 |
|---|---|---|
| Chrome | 51+ | 完全対応 |
| Firefox | 55+ | 完全対応 |
| Safari | 12.1+ | 完全対応 |
| Edge | 15+ | 完全対応 |
| IE | 非対応 | ポリフィル必要 |
if ('IntersectionObserver' in window) {
// IntersectionObserver 使用
} else {
document.querySelectorAll('.lazy-img').forEach((img) => {
(img as HTMLImageElement).src = (img as HTMLImageElement).dataset.src!;
});
}
ベストプラクティス
- 速やかに unobserve:読み込み後すぐ観察を解除し、コールバック削減
- 適切な rootMargin:遅延読み込みは
200-500px、露出トラッキングは0px - 一括観察:1 つの Observer で複数要素を観察
- threshold の選択:遅延読み込み
[0]、プログレスバー[0, 0.25, 0.5, 0.75, 1] - disconnect でクリーンアップ:アンマウント時に
disconnect()でリーク防止
まとめ
IntersectionObserver はモダンフロントエンドにおけるビューポート検出の標準 API で、scroll 監視と比べて桁違いの性能優位性があります。遅延読み込み、無限スクロール、露出トラッキング、アニメーション発火の 4 つのコアユースケースに、rootMargin と threshold で精密な制御が可能です。
画像圧縮 で遅延読み込み画像のサイズを最適化し、画像リサイズ で寸法を統一、画像変換 で WebP 形式に変換することをおすすめします。
ブラウザローカルツールを無料で試す →
#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测