Бесконечная прокрутка

Эта реализация бесконечной прокрутки предназначена для того, чтобы гарантировать отсутствие каких-либо сдвигов макета — независимо от того, сколько времени потребуется серверу, чтобы ответить новым контентом.

Одна из наиболее распространенных проблем во многих реализациях бесконечной прокрутки заключается в том, что нижний колонтитул страницы (или аналогичный UX-элемент) перемещается дальше вниз по странице при каждом добавлении новых элементов. С этой реализацией бесконечной прокрутки этого никогда не происходит.

Высокоуровневый подход

По возможности новые элементы вставляются на страницу до того, как пользователь достигнет их. Поскольку эта вставка происходит за кадром (и не видна пользователю), пользователь не испытывает никаких изменений макета.

Если новый контент не может быть вставлен вовремя, вместо него отображается кнопка «Показать больше». Однако кнопка активируется только тогда, когда новые элементы готовы к отображению — это гарантирует, что пользователь не нажмет кнопку и не обнаружит, что ничего не происходит. Таким образом, независимо от того, насколько медленно сервер отвечает новым контентом (или как быстро пользователь прокручивает страницу), неожиданных сдвигов макета никогда не произойдет.

Выполнение

API Intersection Observer — это эффективный способ мониторинга положения и видимости элементов страницы. Эта конструкция реализована с использованием двух отдельных наблюдателей вставки:

  • listObserver наблюдает за положением #infinite-scroll-button , расположенной в конце списка бесконечной прокрутки. Когда кнопка приближается к области просмотра, в 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();