Мы часто сталкиваемся с запросами на внедрение бесконечного скролла (infinite scroll). На первый взгляд задача простая — подгружать контент по мере скролла. Однако на продакшене всплывают проблемы: потеря позиции при навигации назад, SEO-невидимость для поисковиков, двойные запросы при быстром скролле и нарушение доступности для пользователей клавиатуры. Недавно один клиент с лентой новостей пожаловался, что 30% сессий теряют контекст из-за сброса скролла. Мы внедрили решение на основе Intersection Observer и cursor-based пагинации — после этого повторные запросы снизились в 5 раз, а отказы упали на 12 %. В этой статье разберём промышленное решение на React с Intersection Observer, React Query и cursor-based пагинацией, которое решает все эти проблемы. Наш опыт внедрения — более 50 проектов с infinite scroll, от лент новостей до каталогов товаров. Гарантируем корректную работу на любых устройствах.
Intersection Observer: принцип работы и преимущества перед scroll-событиями
Intersection Observer — это API браузера, которое асинхронно отслеживает пересечение элемента с областью просмотра. Мы создаём невидимый элемент-маяк в конце списка и при его появлении запускаем загрузку следующей порции. Это эффективнее скролл-ивентов: не влияет на производительность, даёт точное срабатывание и потребляет меньше ресурсов CPU. Ниже — базовая реализация на TypeScript:
class InfiniteScroller { private page = 1 private loading = false private hasMore = true private sentinel: HTMLElement constructor( private container: HTMLElement, private fetchFn: (page: number) => Promise<{ items: any[]; hasMore: boolean }>, private renderFn: (items: any[]) => void, ) { this.sentinel = this.createSentinel() this.observe() } private createSentinel(): HTMLElement { const el = document.createElement('div') el.className = 'infinite-sentinel' el.setAttribute('aria-hidden', 'true') this.container.appendChild(el) return el } private observe() { const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting && !this.loading && this.hasMore) { this.loadMore() } }, { rootMargin: '300px' } ) observer.observe(this.sentinel) } private async loadMore() { this.loading = true this.showLoader() try { const { items, hasMore } = await this.fetchFn(this.page) this.renderFn(items) this.hasMore = hasMore this.page++ if (!hasMore) { this.sentinel.remove() this.showEndMessage() } } catch (err) { this.showError(() => this.loadMore()) } finally { this.loading = false this.hideLoader() } } } В React этот паттерн удобно реализовать через useInfiniteQuery из React Query и хук useInView:
import { useInfiniteQuery } from '@tanstack/react-query' import { useInView } from 'react-intersection-observer' import { useEffect } from 'react' interface Post { id: number; title: string; body: string } interface PostsPage { items: Post[]; nextCursor: number | null; total: number } async function fetchPosts(cursor?: number): Promise<PostsPage> { const params = new URLSearchParams({ limit: '20' }) if (cursor) params.set('cursor', String(cursor)) const res = await fetch(`/api/posts?${params}`) if (!res.ok) throw new Error(`HTTP ${res.status}`) return res.json() } export function InfinitePostList() { const { ref: sentinelRef, inView } = useInView({ rootMargin: '400px' }) const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage, status, } = useInfiniteQuery({ queryKey: ['posts'], queryFn: ({ pageParam }) => fetchPosts(pageParam as number | undefined), initialPageParam: undefined, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, staleTime: 5 * 60 * 1000, }) useEffect(() => { if (inView && hasNextPage && !isFetchingNextPage) { fetchNextPage() } }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]) if (status === 'pending') return <PostListSkeleton count={5} /> if (status === 'error') return <ErrorMessage error={error} /> const allPosts = data.pages.flatMap(page => page.items) const total = data.pages[0]?.total ?? 0 return ( <div> <p className="results-count" aria-live="polite"> Показано {allPosts.length} из {total} </p> <div className="posts-grid" role="feed" aria-busy={isFetchingNextPage}> {allPosts.map(post => ( <PostCard key={post.id} post={post} /> ))} </div> <div ref={sentinelRef} aria-hidden="true" /> {isFetchingNextPage && <PostListSkeleton count={3} />} {!hasNextPage && allPosts.length > 0 && ( <p className="end-message">Все записи загружены</p> )} </div> ) } Cursor-based пагинация: надёжность в сравнении с offset
Offset-пагинация (LIMIT 20 OFFSET 100) даёт сбои при параллельных вставках — новые записи сдвигают страницы, пользователь пропускает или видит дубли. Cursor-based пагинация использует уникальный ключ (например, ID) для выборки следующих записей. Она стабильна, исключает сдвиги и не ломается при добавлении контента. Сравним их:
| Критерий | Offset-пагинация | Cursor-based пагинация |
|---|---|---|
| Стабильность при вставках | Ломается (дубли/пропуски) | Устойчива |
| Производительность на больших данных | Хуже (OFFSET сканирует строки) | Лучше (индексный поиск) |
| Поддержка произвольной страницы | Да | Нет (только вперёд/назад) |
| Реализация на сервере | Простая | Чуть сложнее |
Cursor-based пагинация в 5 раз уменьшает количество повторных запросов по сравнению с offset при частых вставках. Пример реализации на Laravel:
public function index(Request $request): JsonResponse { $limit = min($request->integer('limit', 20), 100); $cursor = $request->integer('cursor', 0); $query = Post::query() ->orderBy('id', 'desc') ->with(['author', 'tags']); if ($cursor > 0) { $query->where('id', '<', $cursor); } $items = $query->limit($limit + 1)->get(); $hasMore = $items->count() > $limit; if ($hasMore) $items->pop(); return response()->json([ 'items' => PostResource::collection($items), 'nextCursor' => $hasMore ? $items->last()->id : null, 'total' => Post::count(), ]); } Как восстановить позицию скролла после перехода?
Главная головная боль infinite scroll — потеря позиции. Пользователь проскроллил 5 страниц, кликнул на статью, вернулся — и снова видит начало. Решение: перед переходом сохраняем window.scrollY в sessionStorage, а после возврата восстанавливаем позицию после рендера. Более продвинутый способ — сохранять весь стейт загруженных страниц в кэше React Query через persistQueryClient. В одном из проектов это сократило время восстановления с 2 секунд до 200 мс.
Доступность: кнопка «Загрузить ещё» как fallback
Infinite scroll плох для пользователей клавиатуры — они не могут добраться до footer. Добавляем кнопку «Загрузить ещё» в конце списка, которая дублирует автозагрузку. Это улучшает accessibility и соответствует WCAG.
Скелетон-загрузка для плавного UX
Чтобы избежать дёрганий, показываем скелетоны во время загрузки. Пример компонента и стилей:
function PostCardSkeleton() { return ( <div className="post-card post-card--skeleton" aria-hidden="true"> <div className="skeleton skeleton--image" /> <div className="skeleton skeleton--title" /> <div className="skeleton skeleton--line" /> <div className="skeleton skeleton--line skeleton--line-short" /> </div> ) } function PostListSkeleton({ count }: { count: number }) { return ( <> {Array.from({ length: count }).map((_, i) => ( <PostCardSkeleton key={i} /> ))} </> ) } /* CSS */ .skeleton { background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: 4px; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } .skeleton--image { height: 200px; margin-bottom: 12px; } .skeleton--title { height: 24px; width: 70%; margin-bottom: 8px; } .skeleton--line { height: 16px; margin-bottom: 6px; } .skeleton--line-short { width: 50%; } Как сделать infinite scroll SEO-дружественным?
Google обычно не прокручивает страницу до бесконечности. Решения:
- Первая страница рендерится на сервере (SSR/SSG).
- Следующие страницы доступны по URL с параметром пагинации.
- Sitemap содержит URL всех записей напрямую.
- Добавьте релевантные мета-теги и canonical URL.
Обработка ошибок и повторные попытки
При сетевых сбоях infinite scroll может остановиться. Настройте retry в React Query: retry: 3, retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000). Также добавьте уведомление об ошибке с кнопкой повторной попытки. У нас был опыт, когда это позволило восстановить загрузку в 95 % случаев временных сбоев.
Что входит в работу под ключ?
- Анализ текущей пагинации и данных
- Проектирование архитектуры (Frontend + Backend)
- Реализация Intersection Observer и React Query
- Внедрение cursor-based API на сервере
- Обработка ошибок и retry
- Восстановление позиции скролла
- Скелетон-загрузка и прелоадеры
- Полная accessibility (кнопка fallback, ARIA-атрибуты)
- SEO-оптимизация (SSR первой страницы, отдельные URL)
- Тестирование и деплой
Сроки внедрения
| Этап | Срок |
|---|---|
| Базовая реализация (Intersection Observer + прелоадер) | от 1 дня |
| React Query + cursor-based API + восстановление позиции | 2–3 дня |
| Полный цикл с accessibility, SSR и SEO | 4–5 дней |
Стоимость рассчитывается индивидуально после анализа вашего проекта. Оценим объём и предложим оптимальное решение. Свяжитесь с нами для консультации — мы гарантируем качественную реализацию infinite scroll с учётом всех нюансов.







