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!;
  });
}

ベストプラクティス

  1. 速やかに unobserve:読み込み後すぐ観察を解除し、コールバック削減
  2. 適切な rootMargin:遅延読み込みは 200-500px、露出トラッキングは 0px
  3. 一括観察:1 つの Observer で複数要素を観察
  4. threshold の選択:遅延読み込み [0]、プログレスバー [0, 0.25, 0.5, 0.75, 1]
  5. disconnect でクリーンアップ:アンマウント時に disconnect() でリーク防止

まとめ

IntersectionObserver はモダンフロントエンドにおけるビューポート検出の標準 API で、scroll 監視と比べて桁違いの性能優位性があります。遅延読み込み、無限スクロール、露出トラッキング、アニメーション発火の 4 つのコアユースケースに、rootMargin と threshold で精密な制御が可能です。

画像圧縮 で遅延読み込み画像のサイズを最適化し、画像リサイズ で寸法を統一、画像変換 で WebP 形式に変換することをおすすめします。

ブラウザローカルツールを無料で試す →

#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测