البرمجة متوسط 10 دقيقة

كيفية تنفيذ التمرير اللانهائي باستخدام IntersectionObserver

التقنية الكلاسيكية للتمرير اللانهائي تستمع إلى حدث scroll وتستدعي getBoundingClientRect() للتحقق من اقتراب أسفل القائمة من viewport. هذا النهج يُطلق callbacks عشرات المرات في الثانية ويفرض إعادة حساب التخطيط — وهو فخ أداء موثق جيداً.

IntersectionObserver يحل هذا على مستوى المنصة. بدل استطلاع موضع التمرير، تضع عنصراً صغيراً "كحارس" أسفل قائمتك وتطلب من المتصفح إخطارك حين يدخل viewport. يُجمّع المتصفح فحوصات التقاطع خارج الـ main thread — لا مستمع scroll، ولا تخطيط مُجبر، ولا انهيار في الأداء.

يبني هذا الدليل النمط الكامل من الصفر: إعداد الحارس، وتهيئة المراقب، وتتبع حالة الصفحة، ومنع الطلبات المتكررة، ومعالجة نهاية البيانات، وزر احتياطي أنيق للمستخدمين الذين يصلون إلى النهاية.

الخطوات

  1. 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. 2

    أعدّ IntersectionObserver

    أنشئ المراقب بـ callback وخيارات. rootMargin بقيمة "200px" تأمر المتصفح بالتشغيل قبل 200 بكسل من وصول الحارس إلى حافة viewport — مما يمنحك وقتاً للتحميل حتى لا يرى المستخدمون فجوة فارغة.

    javascript
    const 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. 3

    تتبع حالة الصفحة بمتغيرات على مستوى الوحدة

    احتفظ بالصفحة الحالية وعلامة loading وعلامة hasMore خارج الدالة. تمنع loading الطلبات المتداخلة حين يُطلق المراقب عدة مرات (وهذا ممكن). تمنع hasMore الجلب حين يُشير الـ API إلى انعدام المزيد.

    javascript
    let 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. 4

    اجلب صفحة من البيانات

    مغلف بسيط حول fetch يضرب endpoint المُقسَّم على صفحات ويُعيد مصفوفة العناصر. عدّل شكل URL حسب الـ API — رقم صفحة أو cursor أو offset كلها مقبولة.

    javascript
    async 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. 5

    أضف العناصر إلى DOM بأمان

    ابنِ كل عنصر بـcreateElement وtextContent — لا تربط أبداً سلاسل نصية في innerHTML ببيانات الـ API. استخدام DocumentFragment يجمع كل إدراجات DOM في reflow واحد بدل واحد لكل عنصر.

    javascript
    function 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. 6

    أوقف المراقبة حين تنتهي البيانات

    حين يُعيد الـ API صفحة فارغة أو علامة تشير إلى آخر صفحة، أوقف المراقب وأظهر للمستخدم إشارة واضحة بأنه وصل إلى النهاية. اختيارياً، أظهر زر "تحميل المزيد" كخيار احتياطي لمستخدمي لوحة المفاتيح وسيناريوهات إمكانية الوصول.

    javascript
    function 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. 7

    وفّر زر "تحميل المزيد" احتياطياً

    بعض المستخدمين يُعطّلون ميزات JavaScript، وبعض البيئات تحظر IntersectionObserver (نادر لكن ممكن في WebViews القديمة)، وبعض المستخدمين يُفضّلون التنقل الصريح. اربط زراً يدوياً كخيار احتياطي — استدعِ نفس دالة loadNextPage.

    javascript
    const 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. 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 يجعل الكل خالياً من حالات السباق. أضف زر "تحميل المزيد" كخيار احتياطي وستكون قد غطيت كل سيناريوهات المستخدمين.

#JavaScript #UX #IntersectionObserver
العودة إلى جميع الأدلة

هل تحتاج مساعدة في مشروعك؟

احجز استشارة مجانية لمدة 30 دقيقة لمناقشة تحدياتك التقنية واستكشاف الحلول معًا.