Реализация бесконечной прокрутки (Infinite Scroll) на сайте

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация бесконечной прокрутки (Infinite Scroll) на сайте
Средняя
от 1 рабочего дня до 3 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Реализация бесконечной прокрутки (Infinite Scroll) на сайте

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

Механика работы

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

IntersectionObserver — современный и производительный. Браузер сам уведомляет, когда sentinel-элемент (пустой div в конце списка) входит в зону видимости. Не нагружает поток событий scroll.

scroll event — устаревший способ. Требует throttle/debounce, вычисления scrollTop + clientHeight >= scrollHeight. Избегать на мобильных устройствах.

Реализация на React

// hooks/useInfiniteScroll.ts
import { useEffect, useRef, useCallback } from 'react'

interface UseInfiniteScrollOptions {
  onLoadMore: () => void
  hasMore: boolean
  isLoading: boolean
  threshold?: number // px до края, при котором триггерится загрузка
}

export function useInfiniteScroll({
  onLoadMore,
  hasMore,
  isLoading,
  threshold = 200,
}: UseInfiniteScrollOptions) {
  const sentinelRef = useRef<HTMLDivElement>(null)

  const handleIntersect = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const [entry] = entries
      if (entry.isIntersecting && hasMore && !isLoading) {
        onLoadMore()
      }
    },
    [onLoadMore, hasMore, isLoading]
  )

  useEffect(() => {
    const sentinel = sentinelRef.current
    if (!sentinel) return

    const observer = new IntersectionObserver(handleIntersect, {
      rootMargin: `${threshold}px`,
    })

    observer.observe(sentinel)
    return () => observer.disconnect()
  }, [handleIntersect, threshold])

  return sentinelRef
}
// components/ProductList.tsx
import { useState, useCallback } from 'react'
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'

interface Product {
  id: number
  title: string
  image: string
}

interface Page {
  data: Product[]
  nextCursor: string | null
}

async function fetchProducts(cursor: string | null): Promise<Page> {
  const params = cursor ? `?cursor=${cursor}` : ''
  const res = await fetch(`/api/products${params}`)
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  return res.json()
}

export function ProductList() {
  const [items, setItems] = useState<Product[]>([])
  const [cursor, setCursor] = useState<string | null>(null)
  const [hasMore, setHasMore] = useState(true)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const loadMore = useCallback(async () => {
    if (isLoading || !hasMore) return
    setIsLoading(true)
    setError(null)
    try {
      const page = await fetchProducts(cursor)
      setItems(prev => [...prev, ...page.data])
      setCursor(page.nextCursor)
      setHasMore(page.nextCursor !== null)
    } catch (e) {
      setError('Ошибка загрузки. Попробуйте ещё раз.')
    } finally {
      setIsLoading(false)
    }
  }, [cursor, hasMore, isLoading])

  // Загружаем первую страницу при монтировании
  // useEffect(() => { loadMore() }, []) — опустить для краткости

  const sentinelRef = useInfiniteScroll({ onLoadMore: loadMore, hasMore, isLoading })

  return (
    <div>
      <ul className="grid grid-cols-3 gap-4">
        {items.map(p => (
          <li key={p.id}>
            <img src={p.image} alt={p.title} loading="lazy" />
            <span>{p.title}</span>
          </li>
        ))}
      </ul>

      {error && (
        <button onClick={loadMore} className="mt-4 btn-retry">
          {error}
        </button>
      )}

      {isLoading && <Spinner />}

      {/* Sentinel — невидимый триггер */}
      <div ref={sentinelRef} aria-hidden="true" />

      {!hasMore && <p className="text-center text-muted">Все товары загружены</p>}
    </div>
  )
}

Серверная часть: cursor-based пагинация

Offset-пагинация (LIMIT 20 OFFSET 100) ломается при добавлении новых записей — пользователь получает дубли или пропускает элементы. Cursor-based решает проблему:

-- Первый запрос
SELECT id, title, image, created_at
FROM products
WHERE is_active = true
ORDER BY created_at DESC, id DESC
LIMIT 21; -- +1 чтобы понять, есть ли следующая страница

-- Следующий запрос (cursor = base64(created_at + ':' + id))
SELECT id, title, image, created_at
FROM products
WHERE is_active = true
  AND (created_at, id) < ('2024-11-15 10:30:00', 4821)
ORDER BY created_at DESC, id DESC
LIMIT 21;
// Laravel контроллер
public function index(Request $request): JsonResponse
{
    $limit = 20;
    $cursor = $request->input('cursor');

    $query = Product::where('is_active', true)
        ->orderByDesc('created_at')
        ->orderByDesc('id');

    if ($cursor) {
        [$date, $id] = explode(':', base64_decode($cursor));
        $query->where(function ($q) use ($date, $id) {
            $q->where('created_at', '<', $date)
              ->orWhere(function ($q2) use ($date, $id) {
                  $q2->where('created_at', $date)->where('id', '<', $id);
              });
        });
    }

    $items = $query->limit($limit + 1)->get();
    $hasMore = $items->count() > $limit;
    $data = $items->take($limit);

    $nextCursor = $hasMore
        ? base64_encode($data->last()->created_at . ':' . $data->last()->id)
        : null;

    return response()->json([
        'data' => ProductResource::collection($data),
        'nextCursor' => $nextCursor,
    ]);
}

React Query / TanStack Query

Для продакшена лучше использовать useInfiniteQuery — он берёт на себя кеширование, дедупликацию запросов, фоновое обновление:

import { useInfiniteQuery } from '@tanstack/react-query'

function useProducts() {
  return useInfiniteQuery({
    queryKey: ['products'],
    queryFn: ({ pageParam }) => fetchProducts(pageParam ?? null),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: null,
  })
}

// В компоненте:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useProducts()
const items = data?.pages.flatMap(p => p.data) ?? []

Виртуализация для больших списков

При нескольких сотнях элементов в DOM производительность падает. Решение — виртуальный скролл: рендерится только видимый диапазон.

import { useVirtualizer } from '@tanstack/react-virtual'

function VirtualProductList({ items }: { items: Product[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 200, // высота карточки в px
    overscan: 5,
  })

  return (
    <div ref={parentRef} style={{ height: '100vh', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: virtualItem.start,
              width: '100%',
            }}
          >
            <ProductCard product={items[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  )
}

Доступность и UX

Чистый infinite scroll создаёт проблемы: пользователь не может добраться до футера, теряет позицию после перехода на другую страницу. Практичные решения:

  • Кнопка «Загрузить ещё» вместо автоматического триггера — пользователь контролирует загрузку
  • Сохранение позиции скролла в URL или sessionStorage при переходах
  • Aria-live регион для уведомления скринридеров о новых элементах
  • Кнопка «Наверх» при загрузке более 3 страниц
// Сохранение позиции
useEffect(() => {
  const saved = sessionStorage.getItem('scroll-products')
  if (saved) window.scrollTo(0, parseInt(saved))
  return () => {
    sessionStorage.setItem('scroll-products', String(window.scrollY))
  }
}, [])

Сроки

Базовая реализация IntersectionObserver + API endpoint — 1 день. С cursor-пагинацией на бэкенде, React Query, виртуализацией и восстановлением позиции — 3–4 дня.