Бесконечный скролл (Infinite Scroll)
Зачем нужно
Бесконечный скролл (infinite scroll) -- паттерн подгрузки контента по мере прокрутки страницы. Вместо клика по номерам страниц пользователь просто скроллит, а новые данные загружаются автоматически. Реализация через IntersectionObserver + sentinel-элемент -- современный и производительный подход.
Где используется
- Ленты новостей (Twitter/X, Instagram, Reddit)
- Каталоги товаров
- Результаты поиска
- Галереи изображений
- Комментарии к постам
Архитектура решения
┌─────────────────────────────┐
│ Загруженные элементы │ ← Уже отрисованные карточки
│ ... │
│ Карточка N │
├─────────────────────────────┤
│ 🔭 Sentinel Element │ ← IntersectionObserver следит за ним
├─────────────────────────────┤
│ (невидимая область) │ ← Когда sentinel появляется — загрузка
└─────────────────────────────┘
Базовая реализация
HTML
<div id="feed" class="feed">
<!-- Карточки вставляются сюда -->
</div>
<div id="sentinel" class="sentinel">
<div class="loader" id="loader" hidden>
<span class="loader__spinner"></span>
<span>Загрузка...</span>
</div>
</div>
<div id="endMessage" class="end-message" hidden>
Вы просмотрели все записи
</div>
<div id="errorMessage" class="error-message" hidden>
<p>Ошибка загрузки</p>
<button id="retryBtn" class="btn btn--secondary">Попробовать снова</button>
</div>
CSS
.feed {
max-width: 600px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
padding: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
animation: fadeInUp 0.3s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sentinel {
min-height: 1px;
margin-top: 16px;
}
/* Спиннер загрузки */
.loader {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
}
.loader__spinner {
width: 24px;
height: 24px;
border: 3px solid #e0e0e0;
border-top-color: #333;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.end()-message {
text-align: center;
padding: 24px;
color: #999;
}
.error-message {
text-align: center;
padding: 24px;
color: #e53e3e;
}
JavaScript
class InfiniteScroll {
constructor({ feedEl, sentinelEl, loaderEl, endMessageEl, errorEl, retryBtn, fetchFn }) {
this.feed = feedEl;
this.sentinel = sentinelEl;
this.loader = loaderEl;
this.endMessage = endMessageEl;
this.errorEl = errorEl;
this.retryBtn = retryBtn;
this.fetchFn = fetchFn;
this.page = 1;
this.isLoading = false;
this.hasMore = true;
this.init;
}
init {
// IntersectionObserver на sentinel-элемент
this.observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !this.isLoading && this.hasMore) {
this.loadMore;
}
},
{
root: null,
rootMargin: '200px', // Предзагрузка за 200px до появления
threshold: 0,
}
);
this.observer.observe(this.sentinel);
// Кнопка "Попробовать снова"
this.retryBtn?.addEventListener('click', () => {
this.errorEl.hidden = true;
this.loadMore;
});
// Первая загрузка
this.loadMore;
}
async loadMore {
if (this.isLoading || !this.hasMore) return;
this.isLoading = true;
this.loader.hidden = false;
this.errorEl.hidden = true;
try {
const { items, hasMore } = await this.fetchFn(this.page);
// Отрисовка элементов
items.forEach((item) => {
this.feed.appendChild(this.renderCard(item));
});
this.page++;
this.hasMore = hasMore;
if (!hasMore) {
this.observer.disconnect();
this.endMessage.hidden = false;
}
} catch (error) {
console.error('Ошибка загрузки:', error);
this.errorEl.hidden = false;
} finally {
this.isLoading = false;
this.loader.hidden = true;
}
}
renderCard(data) {
const card = document.createElement('article');
card.className = 'card';
card.innerHTML = `
<h3 class="card__title">${data.title}</h3>
<p class="card__text">${data.body}</p>
`;
return card;
}
// Сброс (например, при смене фильтров)
reset {
this.page = 1;
this.hasMore = true;
this.feed.innerHTML = '';
this.endMessage.hidden = true;
this.errorEl.hidden = true;
this.observer.observe(this.sentinel);
this.loadMore;
}
destroy {
this.observer.disconnect();
}
}
Инициализация с API
// Функция загрузки данных из API
async function fetchPosts(page) {
const limit = 10;
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${limit}`
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const items = await response.json();
const total = parseInt(response.headers.get('x-total-count') || '100');
const hasMore = page * limit < total;
return { items, hasMore };
}
// Инициализация
const infiniteScroll = new InfiniteScroll({
feedEl: document.getElementById('feed'),
sentinelEl: document.getElementById('sentinel'),
loaderEl: document.getElementById('loader'),
endMessageEl: document.getElementById('endMessage'),
errorEl: document.getElementById('errorMessage'),
retryBtn: document.getElementById('retryBtn'),
fetchFn: fetchPosts,
});
Scroll Restoration (восстановление позиции)
При навигации назад (Back) пользователь должен вернуться к тому месту, где остановился.
class ScrollRestoration {
constructor(key = 'scroll-position') {
this.key = key;
}
save {
const data = {
scrollY: window.scrollY,
page: this.currentPage,
timestamp: Date.now(),
};
sessionStorage.setItem(this.key, JSON.stringify(data));
}
restore {
const raw = sessionStorage.getItem(this.key);
if (!raw) return null;
const data = JSON.parse(raw);
// Не восстанавливать, если прошло больше 30 минут
if (Date.now() - data.timestamp > 30 * 60 * 1000) {
sessionStorage.removeItem(this.key);
return null;
}
return data;
}
clear {
sessionStorage.removeItem(this.key);
}
}
// Использование
const scrollRestore = new ScrollRestoration('feed-scroll');
// Сохранять позицию при уходе со страницы
window.addEventListener('beforeunload', () => scrollRestore.save);
// Восстанавливать при возврате
const savedPosition = scrollRestore.restore;
if (savedPosition) {
// Загрузить все страницы до сохранённой, затем прокрутить
requestAnimationFrame(() => {
window.scrollTo(0, savedPosition.scrollY);
});
}
Концепция Virtual Scrolling
Для очень длинных списков (тысячи элементов) бесконечный скролл может замедлить страницу, потому что DOM растёт. Виртуальный скроллинг решает это -- рендерится только видимая область + буфер.
┌─────────────────────────────┐
│ (буфер сверху: 5 элементов)│ ← DOM есть, но за viewport
├─────────────────────────────┤
│ Видимая область viewport │ ← Пользователь видит 10-15 элементов
├─────────────────────────────┤
│ (буфер снизу: 5 элементов) │ ← DOM есть, но за viewport
└─────────────────────────────┘
Остальные 10,000 элементов -- только данные, без DOM
// Упрощённый принцип virtual scroll
class VirtualScroll {
constructor(container, items, itemHeight) {
this.container = container;
this.items = items;
this.itemHeight = itemHeight;
this.buffer = 5; // Количество элементов-буфера
this.container.style.height = `${items.length * itemHeight}px`;
this.container.style.position = 'relative';
window.addEventListener('scroll', () => this.render);
this.render;
}
render {
const scrollTop = window.scrollY - this.container.offsetTop;
const startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.buffer
);
const endIndex = Math.min(this.items.length,
Math.ceil((scrollTop + window.innerHeight) / this.itemHeight) + this.buffer
);
// Очистить и отрисовать только видимые элементы
this.container.innerHTML = '';
for (let i = startIndex; i < endIndex; i++) {
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.top = `${i * this.itemHeight}px`;
el.style.height = `${this.itemHeight}px`;
el.textContent = this.items[i].title;
this.container.appendChild(el);
}
}
}
На практике для virtual scrolling используют библиотеки:
react-window,@tanstack/virtual,virtual-scroller.
Частые ошибки
| Ошибка | Проблема | Решение |
|---|---|---|
Нет rootMargin |
Загрузка начинается слишком поздно | rootMargin: '200px' для предзагрузки |
| Нет защиты от дублирования | Двойные запросы при быстром скролле | Флаг isLoading |
| Нет обработки ошибок | Ломается при сетевой ошибке | Retry-кнопка |
| Нет scroll restoration | При "Назад" позиция сброшена | sessionStorage + scrollTo |
| DOM растёт бесконечно | Тормозит при 1000+ элементов | Virtual scrolling |
| Нет индикации конца | Пользователь ждёт бесконечно | Сообщение "Все записи загружены" |
Связанные темы
- Ленивая загрузка изображений -- lazy loading внутри подгружаемых карточек
- Скелетон загрузки -- скелетоны при подгрузке страниц
- Слайдер -- альтернативный паттерн навигации
- Drag and Drop -- взаимодействие с подгруженными элементами
Ресурсы
- MDN: IntersectionObserver
- web.dev: Infinite Scroll
- TanStack Virtual -- библиотека виртуального скролла