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

實戰一:圖片懶載入

基礎實作

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

實戰二:無限捲動

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

實戰三:虛擬捲動

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

實戰四:曝光打點

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 不支援 需 polyfill
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. 批量觀察:建立一個 Observer 實例觀察多個元素
  4. threshold 選擇:懶載入 [0],進度條 [0, 0.25, 0.5, 0.75, 1]
  5. disconnect 清理:元件卸載時呼叫 disconnect() 避免記憶體洩漏

總結

IntersectionObserver 是現代前端視窗檢測的標準方案,相比 scroll 監聯有數量級的效能優勢。懶載入、無限捲動、曝光打點、動畫觸發是其四大核心場景,搭配 rootMargin 和 threshold 可實現精細控制。

推薦使用 圖片壓縮 最佳化懶載入圖片體積,圖片調整 統一尺寸,格式轉換 轉為 WebP 格式。

本站提供瀏覽器本地工具,免註冊即可試用 →

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