IntersectionObserver

IntersectionObserver — браузерный API для асинхронного отслеживания пересечения элемента с viewport (или другим элементом). Не блокирует главный поток и заменяет дорогие вычисления на scroll.

Зачем нужно

Раньше для определения видимости элемента слушали scroll и вычисляли getBoundingClientRect — это дорого и блокирует UI. IntersectionObserver делает то же самое эффективно и декларативно.

Где используется

  • Lazy loading изображений
  • Бесконечный скролл (infinite scroll)
  • Анимации при скролле (appear on scroll)
  • Фиксация заголовков (sticky detection)
  • Аналитика (отслеживание просмотров секций)
  • Подгрузка рекламы

Предпосылки

События, DOM дерево, Callback

Базовое использование

// Создание observer
const observer = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    console.log(entry.target);           // наблюдаемый элемент
    console.log(entry.isIntersecting);   // видим ли (boolean)
    console.log(entry.intersectionRatio); // доля видимости (0..1)
    console.log(entry.boundingClientRect); // размеры элемента
    console.log(entry.intersectionRect);   // видимая часть
    console.log(entry.rootBounds);         // размеры root
  });
});

// Начинаем наблюдение
const el = document.querySelector('.target');
observer.observe(el);

// Можно наблюдать несколько элементов одним observer
document.querySelectorAll('.card').forEach(card => {
  observer.observe(card);
});

// Прекращение наблюдения
observer.unobserve(el);   // конкретный элемент
observer.disconnect();     // все элементы

Опции

const observer = new IntersectionObserver(callback, {
  // root — корневой элемент (null = viewport)
  root: null, // или document.querySelector('.scroll-container')

  // rootMargin — отступы от root (как CSS margin)
  rootMargin: '0px',        // по умолчанию
  rootMargin: '100px',      // триггер на 100px раньше (для preload)
  rootMargin: '-50px',      // триггер когда элемент на 50px внутри
  rootMargin: '100px 0px',  // верх/низ и лево/право

  // threshold — при какой доле видимости срабатывать
  threshold: 0,             // как только 1px видим (по умолчанию)
  threshold: 0.5,           // когда 50% видимо
  threshold: 1.0,           // когда 100% видимо
  threshold: [0, 0.25, 0.5, 0.75, 1], // при каждом пороге
});

Lazy loading изображений

// HTML: <img data-src="photo.jpg" alt="Фото" class="lazy">

const lazyObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach(entry => {
    if (!entry.isIntersecting) return;

    const img = entry.target;
    img.src = img.dataset.src;
    img.classList.remove('lazy');
    img.classList.add('loaded');

    observer.unobserve(img); // больше не наблюдаем
  });
}, {
  rootMargin: '200px' // начинаем загрузку за 200px до видимости
});

document.querySelectorAll('img.lazy').forEach(img => {
  lazyObserver.observe(img);
});

// CSS:
// .lazy { opacity: 0; transition: opacity 0.3s; }
// .loaded { opacity: 1; }

Бесконечный скролл

// HTML: <div id="list">...</div><div id="sentinel"></div>

let page = 1;
let loading = false;

const sentinel = document.querySelector('#sentinel');
const list = document.querySelector('#list');

const scrollObserver = new IntersectionObserver(async (entries) => {
  if (!entries[0].isIntersecting || loading) return;

  loading = true;
  const data = await fetchData(++page);

  data.forEach(item => {
    const el = document.createElement('div');
    el.className = 'item';
    el.textContent = item.title;
    list.appendChild(el);
  });

  loading = false;

  // Если данных больше нет — отключаем
  if (data.length === 0) {
    scrollObserver.disconnect();
    sentinel.textContent = 'Больше нет данных';
  }
}, {
  rootMargin: '300px' // подгружаем заранее
});

scrollObserver.observe(sentinel);

Анимация при скролле (Appear on scroll)

const appearObserver = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      // Если анимация одноразовая:
      appearObserver.unobserve(entry.target);
    }
  });
}, {
  threshold: 0.1 // 10% элемента видимо
});

document.querySelectorAll('.animate-on-scroll').forEach(el => {
  appearObserver.observe(el);
});

// CSS:
// .animate-on-scroll { opacity: 0; transform: translateY(30px); transition: all 0.6s; }
// .animate-on-scroll.visible { opacity: 1; transform: translateY(0); }

Sticky detection

// Определяем, когда header «прилипает»
const header = document.querySelector('.sticky-header');

// Создаём sentinel-элемент ПЕРЕД header
const stickySentinel = document.createElement('div');
stickySentinel.style.height = '1px';
header.parentElement.insertBefore(stickySentinel, header);

const stickyObserver = new IntersectionObserver(([entry]) => {
  header.classList.toggle('stuck', !entry.isIntersecting);
}, {
  threshold: 0
});

stickyObserver.observe(stickySentinel);

// CSS:
// .sticky-header { position: sticky; top: 0; }
// .sticky-header.stuck { box-shadow: 0 2px 10px rgba(0,0,0,0.1); }

Отслеживание просмотров (аналитика)

const viewTracker = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
      const id = entry.target.dataset.sectionId;
      analytics.track('section_viewed', { section: id });
      viewTracker.unobserve(entry.target); // считаем один раз
    }
  });
}, {
  threshold: 0.5 // 50% секции видимо
});

document.querySelectorAll('[data-section-id]').forEach(section => {
  viewTracker.observe(section);
});

Частые ошибки

1. Забыть unobserve

// Утечка: observer продолжает наблюдать удалённые элементы
const observer = new IntersectionObserver(callback);
observer.observe(el);
el.remove(); // элемент удалён, но observer ещё живёт

// Правильно
observer.unobserve(el);
el.remove();

2. Callback вызывается при инициализации

// IntersectionObserver вызывает callback СРАЗУ при observe
// с текущим состоянием элемента
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    // Первый вызов: isIntersecting может быть false!
    if (!entry.isIntersecting) return; // не забывайте проверку
    doSomething(entry.target);
  });
});

3. threshold: 1.0 не срабатывает

// threshold: 1.0 требует, чтобы элемент был ПОЛНОСТЬЮ видим
// Если элемент больше viewport — никогда не сработает
const observer = new IntersectionObserver(callback, {
  threshold: 1.0 // проблема для больших элементов
});

// Решение: используйте меньший threshold или rootMargin

Практика

  1. Реализуй lazy loading для списка изображений
  2. Построй бесконечный скролл с загрузкой данных
  3. Добавь анимацию появления секциям при прокрутке
  4. Создай индикатор прогресса чтения статьи (на основе видимых секций)

Связанные темы

Ресурсы