無限スクロール

この無限スクロールの実装は、サーバーが新しいコンテンツで応答するのにかかる時間に関係なく、レイアウト シフトが発生しないように設計されています。

無限スクロールの実装でよく見られる問題の一つに、新しいアイテムが追加されるたびにページのフッター(または同様の UX 要素)がページの下方に押し下げられることがあります。この無限スクロールの実装では、このようなことは起こりません。

アプローチの概要

可能な限り、ユーザーがページにアクセスする前に新しいアイテムがページに挿入されます。この挿入は画面外に行われ、ユーザーには表示されません。そのため、ユーザーのレイアウトが移動することはありません。

新しいコンテンツを時間内に挿入できない場合は、代わりに [もっと見る] ボタンが表示されます。ただし、このボタンは、新しいアイテムを表示する準備ができた場合にのみ有効になります。これにより、ユーザーは何も起こらないと判断するためにボタンをクリックするのを防ぐことができます。したがって、サーバーが新しいコンテンツに応答する速度(またはユーザーのスクロール速度)に関係なく、予期しないレイアウト シフトは発生しません。

実装

Intersection Observer API を使用すると、ページ要素の位置と表示状況を効率的にモニタリングできます。この設計は、次の 2 つの独立したインターサート オブザーバーを使用して実装されています。

  • 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();