Бесконечный скролл (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
Нет индикации конца Пользователь ждёт бесконечно Сообщение "Все записи загружены"

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

Ресурсы