Infinite Scroll: Intersection Observer, React Query ו-SEO
לעתים קרובות אנו נתקלים בבקשות ליישם גלילה אינסופית. במבט ראשון, המשימה פשוטה — לטעון תוכן בזמן שהמשתמש גולל. עם זאת, בסביבת production עולות בעיות: אובדן מיקום בעת ניווט חזרה, חוסר נראות SEO למנועי חיפוש, בקשות כפולות בזמן גלילה מהירה, ובעיות נגישות למשתמשי מקלדת. לקוח עם עדכון חדשות התלונן ש-30% מהסשנים איבדו הקשר עקב איפוס גלילה. יישמנו פתרון המבוסס על Intersection Observer ופאג'ינציה מבוססת cursor — לאחר מכן, בקשות חוזרות ירדו פי 5 ושיעור ה-bounce rate ירד ב-12%. במאמר זה נסקור פתרון מוכן ל-production ב-React המשתמש ב-Intersection Observer, React Query ופאג'ינציה מבוססת cursor הפותר את כל הבעיות הללו. ניסיון היישום שלנו כולל מעל 50 פרויקטים עם גלילה אינסופית, מעדכוני חדשות ועד קטלוגי מוצרים. אנו מבטיחים פעולה תקינה בכל מכשיר.
Intersection Observer: עקרונות פעולה ויתרונות על פני אירועי גלילה
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, דפוס זה מיושם בנוחות באמצעות 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 Query וה-hook useInfiniteQuery:
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: אמינות בהשוואה ל-Offset
פאג'ינציה מבוססת Offset (LIMIT 20 OFFSET 100) נכשלת עם הוספות במקביל — רשומות חדשות מזיזות עמודים, מה שגורם לכפילויות או פריטים חסרים. פאג'ינציה מבוססת Cursor משתמשת במפתח ייחודי (למשל ID) כדי להביא את הסט הבא. היא יציבה, חסינה לשינויים, ולא נשברת כאשר תוכן מתווסף. השוו ביניהם:
| קריטריון | פאג'ינציה מבוססת Offset | פאג'ינציה מבוססת Cursor |
|---|---|---|
| יציבות תחת הוספות | שבורה (כפילויות/חסרים) | יציבה |
| ביצועים על נתונים גדולים | גרועים יותר (OFFSET סורק שורות) | טובים יותר (חיפוש indexed) |
| תמיכה בעמוד שרירותי | כן | לא (קדימה/אחורה בלבד) |
| יישום בצד השרת | פשוט | מעט יותר מורכב |
פאג'ינציה מבוססת Cursor מפחיתה בקשות חוזרות פי 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(),
]);
} איך לשחזר מיקום גלילה לאחר ניווט?
הכאב הראש המרכזי של גלילה אינסופית הוא אובדן מיקום. משתמש גולל דרך 5 עמודים, לוחץ על מאמר, חוזר — ורואה את ההתחלה שוב. פתרון: שמרו את 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> ) } לפני ניווט, ולאחר החזרה, שחזרו את המיקום לאחר רינדור. גישה מתקדמת יותר היא לשמר את כל המצב של עמודים שנטענו במטמון React Query באמצעות 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(), ]); } . בפרויקט אחד, זה צמצם את זמן השחזור מ-2 שניות ל-200 אלפיות השנייה.
נגישות: כפתור "טען עוד" כפתרון גיבוי
גלילה אינסופית אינה טובה למשתמשי מקלדת — הם לא יכולים להגיע ל-footer. אנו מוסיפים כפתור "טען עוד" בסוף הרשימה שמכפיל את הטעינה האוטומטית. זה משפר נגישות ועומד בדרישות WCAG.
טעינת שלד (Skeleton) ל-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%;
} איך להפוך גלילה אינסופית לידידותית ל-SEO?
גוגל בדרך כלל לא גוללת עד אינסוף. פתרונות:
- העמוד הראשון מעובד בשרת (SSR/SSG).
- עמודים הבאים נגישים דרך URL עם פרמטר פאג'ינציה.
- Sitemap מכיל כתובות URL ישירות של כל הפריטים.
- הוסיפו תגיות meta רלוונטיות וכתובת canonical.
טיפול בשגיאות וניסיונות חוזרים
במהלך תקלות רשת, גלילה אינסופית עלולה להיעצר. הגדירו ניסיון חוזר ב-React Query: window.scrollY. כמו כן, הוסיפו התראת שגיאה עם כפתור ניסיון חוזר. הייתה לנו חוויה שבה זה אפשר שחזור ב-95% מהתקלות הזמניות.
מה כלול בעבודה במפתח מוכן?
- ניתוח פאג'ינציה ונתונים קיימים
- עיצוב ארכיטקטורה (Frontend + Backend)
- יישום Intersection Observer ו-React Query
- API מבוסס Cursor בשרת
- טיפול בשגיאות וניסיונות חוזרים
- שחזור מיקום גלילה
- טועני שלד וטוענים מקדימים
- נגישות מלאה (כפתור גיבוי, תכונות ARIA)
- אופטימיזציית SEO (SSR לעמוד הראשון, כתובות URL נפרדות)
- בדיקות והעלאה לאוויר
לוחות זמנים ליישום
| שלב | לוח זמנים |
|---|---|
| יישום בסיסי (Intersection Observer + טוען מקדים) | החל מיום אחד |
| React Query + API מבוסס Cursor + שחזור גלילה | 2–3 ימים |
| מחזור מלא עם נגישות, SSR ו-SEO | 4–5 ימים |
התמחור מחושב באופן אישי לאחר ניתוח הפרויקט שלכם. אנו נעריך את ההיקף ונציע את הפתרון האופטימלי. צרו קשר לייעוץ — אנו מבטיחים יישום איכותי של גלילה אינסופית עם כל הניואנסים בחשבון.







