اسکرول بی نهایت

این پیاده‌سازی اسکرول بی‌نهایت به گونه‌ای طراحی شده است که اطمینان حاصل کند که هرگز تغییری در طرح‌بندی وجود ندارد - صرف نظر از اینکه سرور چقدر طول می‌کشد تا با محتوای جدید پاسخ دهد.

یکی از رایج‌ترین مشکلات بسیاری از پیاده‌سازی‌های اسکرول بی‌نهایت این است که پاورقی صفحه (یا عنصر UX مشابه) هر زمان که آیتم‌های جدیدی اضافه می‌شود، بیشتر به پایین صفحه فشار داده می‌شود. با اجرای این اسکرول بی نهایت، این هرگز اتفاق نمی افتد.

رویکرد سطح بالا

در صورت امکان، موارد جدید قبل از اینکه کاربر به آنها برسد در صفحه درج می شود. از آنجا که این درج خارج از صفحه اتفاق می افتد (و برای کاربر قابل مشاهده نیست)، کاربر هیچ تغییری در طرح را تجربه نمی کند.

در صورتی که محتوای جدید نمی تواند به موقع درج شود، به جای آن دکمه "نمایش بیشتر" نمایش داده می شود. با این حال، دکمه فقط زمانی فعال می شود که موارد جدید آماده نمایش باشند - این تضمین می کند که کاربر روی دکمه کلیک نمی کند تا متوجه شود که هیچ اتفاقی نمی افتد. بنابراین، صرف نظر از اینکه سرور با محتوای جدید چقدر کند پاسخ می‌دهد (یا کاربر با چه سرعتی پیمایش می‌کند)، هرگز تغییر طرح‌بندی غیرمنتظره‌ای رخ نخواهد داد.

پیاده سازی

Intersection Observer API یک روش کارآمد برای نظارت بر موقعیت و دید عناصر صفحه است. این طرح با استفاده از دو ناظر متقاطع مجزا اجرا می شود:

  • listObserver موقعیت #infinite-scroll-button که در انتهای لیست اسکرول بی‌نهایت قرار دارد، مشاهده می‌کند. هنگامی که دکمه نزدیک به viewport است، محتوای درج نشده به DOM اضافه می شود.
  • sentinelObserver موقعیت عنصر #sentinel را مشاهده می کند. هنگامی که نگهبان قابل مشاهده می شود، محتوای بیشتری از سرور درخواست می شود. تنظیم موقعیت نگهبان راهی برای کنترل میزان درخواست محتوای جدید از سرور است.

این تنها راه برای رسیدگی به تغییرات طرح‌بندی ناشی از استفاده از اسکرول بی‌نهایت نیست. راه‌های دیگر برای نزدیک شدن به این مشکل عبارتند از تغییر به صفحه‌بندی، استفاده از مجازی‌سازی فهرست، و تنظیم طرح‌بندی صفحه.

HTML

<div id="infinite-scroll-container">
    <div id="sentinel"></div>
    <div class="item">A</div>
    <div class="item">B</div>
    <div class="item">C</div>
    <div class="item">D</div>
    <div class="item">E</div>
    <button id="infinite-scroll-button" disabled>
        <span class="disabled-text">Loading more items...</span>
        <span class="active-text">Show more</span>
    </button>
</div>

CSS


        :root {
    --active-button-primary: #0080ff;
    --active-button-font:#ffffff;
    --disabled-button-primary: #f5f5f5;
    --disabled-button-secondary: #c4c4c4;
    --disabled-button-font: #000000;
}
#infinite-scroll-container {
    position: relative;
}
#sentinel {
    position: absolute;
    bottom: 150vh;
}
#infinite-scroll-button {
    cursor: pointer;
    border: none;
    padding: 1em;
    width: 100%;
    font-size: 1em;
}
#infinite-scroll-button:enabled {
    color: var(--active-button-font);
    background-color: var(--active-button-primary)
}
#infinite-scroll-button:disabled {
    color: var(--disabled-button-font);
    background-color: var(--disabled-button-primary);
    cursor: not-allowed;
    animation: 3s ease-in-out infinite loadingAnimation;
}
#infinite-scroll-button:enabled .disabled-text {
    display: none;
}
#infinite-scroll-button:disabled .active-text {
    display: none;
}
@keyframes loadingAnimation {
    0% {
        background-color: var(--disabled-button-primary);
    }
    50% {
        background-color: var(--disabled-button-secondary);
    }
    100% {
        background-color: var(--disabled-button-primary);
    }
}
        

JS


        function infiniteScroll() {
    let responseBuffer = [];
    let hasMore;
    let requestPending = false;
    const loadingButtonEl = document.querySelector('#infinite-scroll-button');
    const containerEl = document.querySelector('#infinite-scroll-container');
    const sentinelEl = document.querySelector("#sentinel");
    const insertNewItems = () => {
        while (responseBuffer.length > 0) {
            const data = responseBuffer.shift();
            const el = document.createElement("div");
            el.textContent = data;
            el.classList.add("item");
            el.classList.add("new");
            containerEl.insertBefore(el, loadingButtonEl);
            console.log(`inserted: ${data}`);
        }
        sentinelObserver.observe(sentinelEl);
        if (hasMore === false) {
            loadingButtonEl.style = "display: none";
            sentinelObserver.unobserve(sentinelEl);
            listObserver.unobserve(loadingButtonEl);
        }
        loadingButtonEl.disabled = true
    }
    loadingButtonEl.addEventListener("click", insertNewItems);
    const requestHandler = () => {
        if (requestPending) return;
        console.log("making request");
        requestPending = true;
        fakeServer.fakeRequest().then((response) => {
            console.log("server response", response);
            requestPending = false;
            responseBuffer = responseBuffer.concat(response.items);
            hasMore = response.hasMore;
            loadingButtonEl.disabled = false;;
        });
    }
    const sentinelObserver = new IntersectionObserver((entries, observer) => {
        entries.forEach(entry => {
            if (entry.intersectionRatio > 0) {
                observer.unobserve(sentinelEl);
                requestHandler();
            }
        });
    });
    const listObserver = new IntersectionObserver((entries, observer) => {
        entries.forEach(entry => {
            if (entry.intersectionRatio > 0 && entry.intersectionRatio < 1) {
                insertNewItems();
            }
        });
    }, {
        rootMargin: "0px 0px 200px 0px"
    });
    sentinelObserver.observe(sentinelEl);
    listObserver.observe(loadingButtonEl);
}
infiniteScroll();