IntersectionObserver in Practice: Lazy Loading, Infinite Scroll, and Viewport Detection

性能优化(Updated Jun 2, 2026)

IntersectionObserver: High-Performance Viewport Detection

IntersectionObserver is a native asynchronous viewport intersection API that replaces traditional scroll event listeners:

Aspect scroll listener IntersectionObserver
Execution Main thread, sync Browser internal, async
Trigger frequency Every frame possible Only on intersection change
Performance cost High (manual throttle) Low (browser-optimized)
Computation getBoundingClientRect() Compositor thread
Support All browsers 98%+ global coverage

Core API Deep Dive

Basic Usage

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: Early/Delayed Triggering

// Trigger 200px before entering viewport (preload)
const lazyObserver = new IntersectionObserver(callback, {
  rootMargin: '200px 0px'
});

// Trigger only when fully inside viewport
const fullObserver = new IntersectionObserver(callback, {
  rootMargin: '-100px 0px'
});

threshold: Precise Trigger Control

// Trigger at every 10% intersection
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]
});

// Only when fully visible
const observer2 = new IntersectionObserver(callback, {
  threshold: 1.0
});

Practice 1: Image Lazy Loading

Basic Implementation

<img data-src="photo.webp" alt="description" 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));
  }
}

Progressive Loading (Blur → Sharp)

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

Practice 2: Infinite Scroll

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

Practice 3: Virtual Scrolling

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

Practice 4: Exposure Tracking

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

Performance Comparison

Scenario scroll listener IntersectionObserver Gain
1000 lazy images ~60fps (throttle 200ms) ~60fps (no jank) Smoothness ↑
Scroll events/sec ~300 ~5 60× ↓
getBoundingClientRect 1000 per trigger 0 (browser computes) 100% ↓
Main thread time 15-30ms/frame <1ms/callback 30× ↓

Browser Compatibility

Browser Version Notes
Chrome 51+ Full support
Firefox 55+ Full support
Safari 12.1+ Full support
Edge 15+ Full support
IE None Needs polyfill
if ('IntersectionObserver' in window) {
  // Use IntersectionObserver
} else {
  // Fallback: load all images immediately
  document.querySelectorAll('.lazy-img').forEach((img) => {
    (img as HTMLImageElement).src = (img as HTMLImageElement).dataset.src!;
  });
}

Best Practices

  1. Unobserve promptly: Stop observing after loading to reduce callbacks
  2. Sensible rootMargin: 200-500px for lazy load, 0px for exposure tracking
  3. Batch observations: One Observer for many elements, not one per element
  4. Threshold choice: [0] for lazy load, [0, 0.25, 0.5, 0.75, 1] for progress
  5. Disconnect on cleanup: Call disconnect() on unmount to prevent leaks

Summary

IntersectionObserver is the standard viewport detection API for modern frontends, offering orders-of-magnitude performance gains over scroll listeners. Lazy loading, infinite scroll, exposure tracking, and animation triggers are its four core use cases, with rootMargin and threshold enabling fine-grained control.

Use Image Compress to optimize lazy-loaded image sizes, Image Resize to normalize dimensions, and Image Convert to convert to WebP format.

Try these browser-local tools — no sign-up required →

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