الخطوات
-
1
أضف عنصراً حارساً أسفل القائمة
الحارس هو
<div>عديم الارتفاع موضوع مباشرةً بعد حاوية قائمتك. لا حضور مرئي له — هو مجرد علامة موضع يراقبها المراقب. حين يتمرر إلى داخل العرض، تحمّل الصفحة التالية.html<ul id="post-list"> <!-- Items appended here dynamically --> </ul> <!-- The sentinel: observed by IntersectionObserver --> <div id="scroll-sentinel" aria-hidden="true"></div> <button id="load-more-btn" hidden>Load more</button> -
2
أعدّ IntersectionObserver
أنشئ المراقب بـ callback وخيارات.
rootMarginبقيمة"200px"تأمر المتصفح بالتشغيل قبل 200 بكسل من وصول الحارس إلى حافة viewport — مما يمنحك وقتاً للتحميل حتى لا يرى المستخدمون فجوة فارغة.javascriptconst sentinel = document.getElementById('scroll-sentinel'); const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { loadNextPage(); } }, { root: null, // Observe relative to the browser viewport rootMargin: '200px', // Trigger 200px before the sentinel is visible threshold: 0, // Fire as soon as any pixel enters the margin } ); observer.observe(sentinel); -
3
تتبع حالة الصفحة بمتغيرات على مستوى الوحدة
احتفظ بالصفحة الحالية وعلامة
loadingوعلامةhasMoreخارج الدالة. تمنعloadingالطلبات المتداخلة حين يُطلق المراقب عدة مرات (وهذا ممكن). تمنعhasMoreالجلب حين يُشير الـ API إلى انعدام المزيد.javascriptlet currentPage = 1; let loading = false; let hasMore = true; async function loadNextPage() { if (loading || !hasMore) return; // Guard against overlap and end-of-data loading = true; try { const items = await fetchPage(currentPage); if (items.length === 0) { hasMore = false; observer.unobserve(sentinel); // Stop watching — nothing left to load showEndMessage(); return; } appendItems(items); currentPage++; } catch (err) { showError(err.message); } finally { loading = false; } } -
4
اجلب صفحة من البيانات
مغلف بسيط حول
fetchيضرب endpoint المُقسَّم على صفحات ويُعيد مصفوفة العناصر. عدّل شكل URL حسب الـ API — رقم صفحة أو cursor أو offset كلها مقبولة.javascriptasync function fetchPage(page) { const response = await fetch(`/api/posts?page=${page}&per_page=20`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); // API returns { items: [...], total_pages: N } if (page >= data.total_pages) hasMore = false; return data.items; } -
5
أضف العناصر إلى DOM بأمان
ابنِ كل عنصر بـ
createElementوtextContent— لا تربط أبداً سلاسل نصية فيinnerHTMLببيانات الـ API. استخدامDocumentFragmentيجمع كل إدراجات DOM في reflow واحد بدل واحد لكل عنصر.javascriptfunction appendItems(items) { const list = document.getElementById('post-list'); const fragment = document.createDocumentFragment(); items.forEach((post) => { const li = document.createElement('li'); li.className = 'post-item'; const title = document.createElement('h3'); title.textContent = post.title; // Safe — escapes HTML const excerpt = document.createElement('p'); excerpt.textContent = post.excerpt; li.append(title, excerpt); fragment.appendChild(li); }); list.appendChild(fragment); // One DOM mutation instead of N } -
6
أوقف المراقبة حين تنتهي البيانات
حين يُعيد الـ API صفحة فارغة أو علامة تشير إلى آخر صفحة، أوقف المراقب وأظهر للمستخدم إشارة واضحة بأنه وصل إلى النهاية. اختيارياً، أظهر زر "تحميل المزيد" كخيار احتياطي لمستخدمي لوحة المفاتيح وسيناريوهات إمكانية الوصول.
javascriptfunction showEndMessage() { sentinel.hidden = true; const loadMoreBtn = document.getElementById('load-more-btn'); loadMoreBtn.hidden = false; loadMoreBtn.textContent = 'You\'ve reached the end'; loadMoreBtn.disabled = true; // Or: replace with a friendlier end-of-feed message const msg = document.createElement('p'); msg.className = 'end-of-feed'; msg.textContent = 'No more posts to load.'; document.getElementById('post-list').after(msg); } -
7
وفّر زر "تحميل المزيد" احتياطياً
بعض المستخدمين يُعطّلون ميزات JavaScript، وبعض البيئات تحظر
IntersectionObserver(نادر لكن ممكن في WebViews القديمة)، وبعض المستخدمين يُفضّلون التنقل الصريح. اربط زراً يدوياً كخيار احتياطي — استدعِ نفس دالةloadNextPage.javascriptconst loadMoreBtn = document.getElementById('load-more-btn'); loadMoreBtn.addEventListener('click', () => { loadNextPage(); }); // During loading: update button text so users know something is happening async function loadNextPage() { if (loading || !hasMore) return; loading = true; loadMoreBtn.textContent = 'Loading…'; loadMoreBtn.disabled = true; try { // ... fetch and append logic } finally { loading = false; if (hasMore) { loadMoreBtn.textContent = 'Load more'; loadMoreBtn.disabled = false; } } } -
8
حمّل الصفحة الأولى عند الإعداد الأولي
استدعِ
loadNextPage()مرة واحدة حين تُحمّل الصفحة لتجنب القائمة الفارغة عند أول رسم. سيتولى المراقب كل تحميل لاحق تلقائياً بينما يتمرر المستخدم.javascript// Entry point: load page 1 immediately, then let the observer take over loadNextPage();
نصائح ومحاذير
- اضبط <code>rootMargin</code> على <code>"200px"</code> على الأقل حتى يبدأ التحميل قبل أن يصبح الحارس مرئياً بالكامل — يمنع الفجوات الفارغة عند التمرير السريع.
- لـ APIs المعتمدة على cursor، احتفظ بـ<code>next_cursor</code> من آخر رد بدلاً من زيادة رقم الصفحة.
- إذا كان بإمكان حذف العناصر أثناء تمرير المستخدم، استخدم معرفات فريدة يولدها السيرفر وأزل التكرارات قبل الإضافة: تتبع المعرفات المعروضة في <code>Set</code>.
- للتمرير الافتراضي (آلاف العناصر)، <code>IntersectionObserver</code> وحده لا يكفي — انظر في مكتبات مثل <strong>TanStack Virtual</strong> التي تُعيد استخدام عقد DOM.
- اختبر دائماً مع شبكة مُبطأة في DevTools للتأكد من أن حالة التحميل مرئية وأن الحارس ضد الطلبات المزدوجة يعمل.
خاتمة
IntersectionObserver يحوّل التمرير اللانهائي من كابوس أداء قائم على أحداث scroll إلى نمط تصريحي وفعّال. أسلوب الحارس نظيف، والمراقب يُطلق فقط عند الحاجة، وحارس علامة loading يجعل الكل خالياً من حالات السباق. أضف زر "تحميل المزيد" كخيار احتياطي وستكون قد غطيت كل سيناريوهات المستخدمين.