js

Intersection Observer API в JavaScript: lazy-load, бесконечный скролл и анимация при скролле

Обработчик события scroll срабатывает десятки раз в секунду и заставляет браузер постоянно считать getBoundingClientRect() — это главная причина тормозов при ленивой загрузке картинок и анимациях по скроллу. Intersection Observer API решает задачу иначе: браузер сам асинхронно сообщает, когда элемент попал в зону видимости, не нагружая основной поток. В этой статье разберём, как с его помощью сделать lazy-load изображений, бесконечный скролл и появление блоков при прокрутке — без единого слушателя scroll.

Как устроен Intersection Observer

Observer наблюдает за пересечением целевого элемента (target) с областью видимости (root). Когда степень пересечения проходит заданный порог (threshold), вызывается колбэк. Создаётся наблюдатель в две строки:

// Колбэк получает массив записей — по одной на каждый отслеживаемый элемент
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      console.log('Элемент виден на', entry.intersectionRatio * 100, '%');
    }
  });
}, {
  root: null,           // null = вьюпорт браузера
  rootMargin: '0px',    // отступы, расширяющие/сужающие зону срабатывания
  threshold: 0.1        // сработать, когда видно хотя бы 10% элемента
});

// Начинаем наблюдать за элементом
const target = document.querySelector('.card');
observer.observe(target);

Три параметра конфигурации определяют всё поведение:

  • root — контейнер, относительно которого считается видимость. null означает вьюпорт. Можно передать любой прокручиваемый элемент.
  • rootMargin — CSS-подобные отступы ('100px 0px'). Положительные значения заставляют колбэк срабатывать заранее, ещё до того как элемент реально появится на экране — удобно для предзагрузки.
  • threshold — порог от 0 до 1 или массив порогов. 0 — сработать при появлении первого пикселя, 1 — когда элемент виден целиком.
Схема работы Intersection Observer: root, rootMargin, threshold и isIntersecting
Колбэк вызывается только при смене состояния пересечения, без обработчика scroll

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

Самый частый сценарий. Вместо реального src картинки кладём адрес в data-src, а настоящий источник подставляем только когда изображение приближается к экрану. В разметке оставляем лёгкий плейсхолдер:

<img class="lazy"
     data-src="/images/photo-large.jpg"
     src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"
     alt="Описание фото" width="800" height="600">

JavaScript подставляет настоящий URL за 200 пикселей до появления и сразу отписывается от элемента, чтобы не тратить ресурсы повторно:

const lazyImages = document.querySelectorAll('img.lazy');

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

    const img = entry.target;
    img.src = img.dataset.src;        // подставляем настоящий источник
    img.removeAttribute('data-src');
    img.classList.add('loaded');

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

lazyImages.forEach((img) => imgObserver.observe(img));

Плавное проявление добавим через CSS — пара строк делает загрузку визуально аккуратной:

img.lazy { opacity: 0; transition: opacity .4s ease; }
img.lazy.loaded { opacity: 1; }

Современные браузеры умеют нативный loading="lazy", но Intersection Observer даёт контроль над моментом загрузки, своими отступами и анимацией — это важно для галерей и тяжёлых страниц.


Бесконечный скролл (infinite scroll)

Идея проста: ставим невидимый «маяк» в конце ленты. Как только он попадает в зону видимости — подгружаем следующую порцию данных. Никаких вычислений высоты документа и позиции скролла.

const sentinel = document.querySelector('#sentinel'); // пустой div в конце списка
const list = document.querySelector('#posts');
let page = 1;
let loading = false;

const loadMore = async () => {
  if (loading) return;          // защита от двойной загрузки
  loading = true;

  const res = await fetch(`/api/posts?page=${page}`);
  const posts = await res.json();

  if (posts.length === 0) {
    scrollObserver.disconnect(); // данные кончились — отключаем наблюдатель
    return;
  }

  posts.forEach((post) => {
    const el = document.createElement('article');
    el.className = 'post';
    el.textContent = post.title;
    list.insertBefore(el, sentinel);
  });

  page++;
  loading = false;
};

const scrollObserver = new IntersectionObserver((entries) => {
  if (entries[0].isIntersecting) loadMore();
}, { rootMargin: '300px' });    // запускаем подгрузку за 300px до конца

scrollObserver.observe(sentinel);

Флаг loading здесь обязателен: пока запрос летит к серверу, маяк может оставаться в зоне видимости и спровоцировать десяток лишних запросов. Когда сервер вернул пустой массив, вызываем disconnect() — это полностью останавливает наблюдатель и освобождает память.


Анимация появления блоков при скролле

Эффект «выезжающих» секций делается без сторонних библиотек. Прячем элементы классом, а при пересечении добавляем класс видимости:

.reveal {
  opacity: 0;
  transform: translateY(40px);
  transition: opacity .6s ease, transform .6s ease;
}
.reveal.visible {
  opacity: 1;
  transform: translateY(0);
}
const revealObserver = new IntersectionObserver((entries, observer) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      observer.unobserve(entry.target); // анимируем один раз
    }
  });
}, { threshold: 0.15 });               // сработать, когда видно 15% блока

document.querySelectorAll('.reveal').forEach((el) => {
  revealObserver.observe(el);
});

Если анимация должна повторяться при каждом заходе элемента в кадр — просто не вызывайте unobserve, а в ветке else снимайте класс visible.


Производительность и подводные камни

Несколько правил, которые экономят часы отладки:

  • Один наблюдатель на много элементов. Не создавайте по IntersectionObserver на каждую картинку — один экземпляр спокойно держит сотни observe().
  • Всегда отписывайтесь. unobserve(target) для разовых событий, disconnect() когда наблюдатель больше не нужен — иначе утечки памяти на SPA.
  • Колбэк асинхронный. Он выполняется не мгновенно, а в момент простоя браузера. Не рассчитывайте на синхронное срабатывание сразу после observe().
  • Динамический контент. Элементы, добавленные в DOM после создания наблюдателя, нужно явно передать в observe() — автоматически они не подхватываются.

Проверить поддержку и при необходимости подгрузить полифилл можно так:

if ('IntersectionObserver' in window) {
  // нативная реализация — поддержка во всех актуальных браузерах
  initObservers();
} else {
  // старый браузер: грузим полифилл или откатываемся на обычную загрузку
  document.querySelectorAll('img.lazy').forEach((img) => {
    img.src = img.dataset.src;
  });
}

Итог

Intersection Observer API закрывает целый класс задач, где раньше приходилось вешать тяжёлые обработчики scroll и resize:

  • Lazy-load — подставляем src из data-src с запасом по rootMargin и отписываемся.
  • Бесконечный скролл — невидимый маяк в конце списка плюс флаг загрузки от дублей.
  • Scroll-анимации — класс видимости по threshold, без библиотек.
  • Производительность — один наблюдатель на множество элементов, обязательный unobserve/disconnect.

Браузер делает всю тяжёлую работу за вас асинхронно — страница остаётся отзывчивой даже на длинных лентах и тяжёлых галереях.