W3docs

JavaScript Intersection Observer API

Изучите JavaScript Intersection Observer API для отслеживания видимости элементов — ленивая загрузка, бесконечный скролл и анимации по прокрутке.

Intersection Observer API позволяет попросить браузер сообщать вам, когда элемент появляется в видимой части страницы или покидает её. Это происходит эффективно и асинхронно, без затрат на производительность при самостоятельном отслеживании событий прокрутки. Это правильный инструмент для ленивой загрузки изображений, реализации бесконечного скролла, запуска анимаций при появлении контента и отслеживания того, было ли рекламное объявление или баннер действительно просмотрено.

Проблема, которую он решает

До появления этого API ответить на простой вопрос «находится ли этот элемент сейчас на экране?» было удивительно сложно. Нужно было подписываться на событие scroll (и нередко resize), а затем вызывать getBoundingClientRect() для каждого отслеживаемого элемента, чтобы сравнить его позицию с viewport.

// The old, expensive way — runs on every scroll tick.
window.addEventListener('scroll', () => {
  const rect = element.getBoundingClientRect();
  const inView = rect.top < window.innerHeight && rect.bottom > 0;
  if (inView) {
    // do something
  }
});

События прокрутки срабатывают десятки раз в секунду, а getBoundingClientRect() заставляет браузер пересчитывать макет («reflow»). Выполнение этой работы синхронно в главном потоке во время прокрутки — классический источник задержек. (Подробнее о поведении этих событий см. в Обработка событий в DOM и JavaScript Scrolling.)

IntersectionObserver меняет модель. Вместо того чтобы вы сами опрашивали позиции, браузер следит за элементами и вызывает обратный вызов только тогда, когда видимость действительно изменилась. Работа происходит вне главного потока, поэтому не блокирует прокрутку. Подробнее о важности этого читайте в DOM Performance Optimization. Этот API близок к MutationObserver, который следит за изменениями структуры DOM, а не видимости.

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

Вы создаёте наблюдатель с обратным вызовом, а затем указываете, какие элементы наблюдать с помощью observe().

// 1. Create an observer with a callback and (optional) options.
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log('Element is now visible:', entry.target);
    } else {
      console.log('Element left the viewport:', entry.target);
    }
  });
});

// 2. Start watching a target element.
const target = document.querySelector('#box');
observer.observe(target);

Обратный вызов получает array записей, по одной для каждого наблюдаемого элемента, видимость которого изменилась. Один наблюдатель может следить за многими элементами — это рекомендуемый подход: создайте один наблюдатель и вызывайте observe() для каждой цели, а не создавайте отдельный наблюдатель для каждого элемента.

Информация

Обратный вызов выполняется асинхронно, а изменения объединяются в пакеты — браузер может сообщить о нескольких записях за один вызов. Он также срабатывает один раз сразу после начала наблюдения, так что вы получаете начальное состояние видимости элемента без ожидания прокрутки. Поддержка браузерами отличная во всех современных браузерах.

Настройка наблюдателя

Второй аргумент конструктора — объект настроек с тремя свойствами.

root

Элемент, используемый в качестве viewport для проверки видимости. Цель должна быть потомком root. Когда root равен null (по умолчанию), используется собственный viewport браузера.

const observer = new IntersectionObserver(callback, {
  root: document.querySelector('#scroll-container'),
});

rootMargin

Отступ вокруг root, записывается как значение CSS margin. Он увеличивает или уменьшает область, используемую для проверки пересечений. Распространённый приём — положительный нижний отступ, чтобы элементы сообщались как «видимые» до того, как они фактически появятся в зоне прокрутки — полезно для раннего начала загрузки контента.

const observer = new IntersectionObserver(callback, {
  // Trigger 200px before the element reaches the bottom edge.
  rootMargin: '0px 0px 200px 0px',
});

threshold

Число от 0 до 1 или array чисел, указывающий наблюдателю, при каких соотношениях видимости срабатывать. 0 означает «срабатывать, как только виден хотя бы один пиксель», 1 означает «срабатывать только когда элемент полностью виден». Array срабатывает при каждом указанном соотношении.

const observer = new IntersectionObserver(callback, {
  // Fire at 0%, 50%, and 100% visibility.
  threshold: [0, 0.5, 1],
});

Что содержится в записи

Каждый объект в array entries описывает видимость одного элемента в момент выполнения обратного вызова. Наиболее полезные свойства:

  • isIntersecting — boolean: true, если элемент в данный момент виден в пределах root.
  • intersectionRatio — какая часть элемента видна, от 0 до 1.
  • target — наблюдаемый элемент.
  • boundingClientRect — размер и позиция цели.
  • intersectionRect — видимая часть цели.
  • rootBounds — прямоугольник root (с учётом rootMargin).
  • time — временная метка момента фиксации изменения.
const observer = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    console.log(entry.target.id, 'visible:', entry.isIntersecting);
    console.log('ratio:', entry.intersectionRatio.toFixed(2));
  }
});

Методы: observe, unobserve, disconnect

Экземпляр наблюдателя предоставляет три метода:

  • observe(element) — начать наблюдение за элементом.
  • unobserve(element) — прекратить наблюдение за одним элементом.
  • disconnect() — прекратить наблюдение за всеми элементами сразу.

Важная рекомендация: как только элемент выполнил свою единовременную задачу — например, изображение завершило ленивую загрузку — вызовите для него unobserve(), чтобы браузер перестал отслеживать то, что больше не изменится.

Вариант использования 1 — Ленивая загрузка изображений

Ленивая загрузка откладывает скачивание изображений до момента, когда они вот-вот появятся на экране. Поместите реальный URL в атрибут data-src, наблюдайте за каждым изображением и переносите его в src, когда оно становится видимым — затем прекратите за ним наблюдать.

<img data-src="photo-1.jpg" alt="First photo" width="600" height="400" />
<img data-src="photo-2.jpg" alt="Second photo" width="600" height="400" />
<img data-src="photo-3.jpg" alt="Third photo" width="600" height="400" />
const images = document.querySelectorAll('img[data-src]');

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

    const img = entry.target;
    img.src = img.dataset.src;        // load the real image
    img.removeAttribute('data-src');
    observer.unobserve(img);          // job done — stop watching it
  });
}, { rootMargin: '0px 0px 200px 0px' }); // start loading a little early

images.forEach((img) => imageObserver.observe(img));
Примечание

Современные браузеры также поддерживают нативный атрибут loading="lazy" на <img> и <iframe>, который не требует никакого JavaScript. Используйте IntersectionObserver, когда нужно нестандартное поведение — замена заглушки, плавное появление или загрузка не-изображений.

Вариант использования 2 — Бесконечный скролл

Для бесконечного скролла разместите пустой элемент-«маяк» в конце списка. Когда маяк появляется в зоне видимости, загружайте следующую страницу данных и добавляйте её. Поскольку маяк остаётся внизу, тот же наблюдатель продолжает срабатывать по мере прокрутки пользователем.

<ul id="list"></ul>
<div id="sentinel"></div>
const list = document.querySelector('#list');
const sentinel = document.querySelector('#sentinel');
let page = 1;
let loading = false;

async function loadMore() {
  if (loading) return;            // guard against overlapping loads
  loading = true;

  const res = await fetch('/api/items?page=' + page);
  const items = await res.json();

  items.forEach((item) => {
    const li = document.createElement('li');
    li.textContent = item.title;
    list.appendChild(li);
  });

  page += 1;
  loading = false;
}

const scrollObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) {
    loadMore();
  }
});

scrollObserver.observe(sentinel);

Вариант использования 3 — Анимации при появлении в зоне прокрутки

Популярный эффект — плавное появление или выезд элементов при входе в viewport. Держите анимацию в CSS и позвольте JavaScript добавлять класс в нужный момент.

.reveal {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}
const revealItems = document.querySelectorAll('.reveal');

const revealObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('is-visible');
      observer.unobserve(entry.target); // animate only once
    }
  });
}, { threshold: 0.15 }); // fire when ~15% is showing

revealItems.forEach((el) => revealObserver.observe(el));

Вариант использования 4 — Отслеживание показов / видимости

Аналитика нередко требует знать, был ли контент действительно просмотрен, а не просто присутствовал в DOM. Более высокий threshold позволяет фиксировать показ только тогда, когда значимая часть элемента видна.

const adObserver = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.intersectionRatio >= 0.5) {
      sendImpression(entry.target.dataset.adId);
      adObserver.unobserve(entry.target); // count each ad once
    }
  });
}, { threshold: 0.5 }); // at least 50% visible

document.querySelectorAll('.ad').forEach((ad) => adObserver.observe(ad));

Это можно дополнить таймером, требующим, например, одной полной секунды 50% видимости перед засчитыванием показа — распространённый стандарт для «видимых» рекламных объявлений.

Итог

Intersection Observer API заменяет хрупкие и ресурсоёмкие слушатели прокрутки чистым асинхронным способом реагирования на видимость элементов. Создайте один наблюдатель, укажите ему цели через observe(), читайте isIntersecting и intersectionRatio в обратном вызове и вызывайте unobserve(), когда задача элемента выполнена. С помощью root, rootMargin и threshold вы можете точно настроить момент срабатывания — это делает ленивую загрузку, бесконечный скролл, анимации прокрутки и отслеживание показов одновременно простыми и плавными.

Проверьте свои знания

Практика
Какое свойство записи сообщает, находится ли цель в данный момент в пределах root?
Какое свойство записи сообщает, находится ли цель в данный момент в пределах root?
Практика
Почему IntersectionObserver предпочтительнее слушателя прокрутки с вызовом getBoundingClientRect()?
Почему IntersectionObserver предпочтительнее слушателя прокрутки с вызовом getBoundingClientRect()?
Практика
Какие утверждения о настройках конструктора верны?
Какие утверждения о настройках конструктора верны?
Was this page helpful?