IntersectionObserver на практике: ленивая загрузка, бесконечный скроллинг и обнаружение viewport

性能优化

IntersectionObserver: высокопроизводительное обнаружение viewport

IntersectionObserver — это нативный асинхронный API пересечения с viewport, который заменяет традиционные слушатели событий 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: раннее/отложенное срабатывание

// 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: точный контроль срабатывания

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

Практика 1: Ленивая загрузка изображений

Базовая реализация

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

Прогрессивная загрузка (размытое → чёткое)

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 lazy-изображений ~60fps (throttle 200ms) ~60fps (без рывков) Плавность ↑
Scroll-событий/сек ~300 ~5 60× ↓
getBoundingClientRect 1000 за срабатывание 0 (считает браузер) 100% ↓
Время основного потока 15-30мс/кадр <1мс/вызов 30× ↓

Совместимость с браузерами

Браузер Версия Примечания
Chrome 51+ Полная поддержка
Firefox 55+ Полная поддержка
Safari 12.1+ Полная поддержка
Edge 15+ Полная поддержка
IE Нет Требуется 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!;
  });
}

Лучшие практики

  1. Своевременно вызывайте unobserve: прекращайте наблюдение после загрузки, чтобы уменьшить число колбэков
  2. Разумное rootMargin: 200-500px для ленивой загрузки, 0px для отслеживания показа
  3. Группируйте наблюдения: один Observer для многих элементов, а не по одному на элемент
  4. Выбор threshold: [0] для ленивой загрузки, [0, 0.25, 0.5, 0.75, 1] для прогресса
  5. Disconnect при очистке: вызывайте disconnect() при размонтировании, чтобы избежать утечек памяти

Резюме

IntersectionObserver — это стандартный API обнаружения viewport для современных фронтендов, обеспечивающий выигрыш в производительности на порядки выше, чем у слушателей scroll. Ленивая загрузка, бесконечный скроллинг, отслеживание показа и триггеры анимации — четыре его основных сценария использования, а rootMargin и threshold обеспечивают тонкий контроль.

Используйте Image Compress для оптимизации размера лениво загружаемых изображений, Image Resize для нормализации размеров и Image Convert для конвертации в формат WebP.

Попробуйте эти локальные браузерные инструменты — регистрация не требуется →

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