Обработчик события 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— когда элемент виден целиком.

Ленивая загрузка изображений
Самый частый сценарий. Вместо реального 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.
Браузер делает всю тяжёлую работу за вас асинхронно — страница остаётся отзывчивой даже на длинных лентах и тяжёлых галереях.
