Cuộn vô hạn

Cách triển khai chức năng cuộn vô hạn này được thiết kế để đảm bảo không bao giờ có bất kỳ thay đổi nào về bố cục, bất kể máy chủ cần bao lâu để phản hồi nội dung mới.

Một trong những vấn đề thường gặp nhất khi triển khai tính năng cuộn vô hạn là phần chân trang (hoặc phần tử trải nghiệm người dùng tương tự) bị đẩy sâu hơn xuống dưới trang bất cứ khi nào có mục mới được thêm. Với cách triển khai chức năng cuộn vô hạn này, điều này không bao giờ xảy ra.

Phương pháp tổng quát

Bất cứ khi nào có thể, các mục mới sẽ được chèn vào trang trước khi người dùng truy cập các mục đó. Vì hoạt động chèn này diễn ra ngoài màn hình (và người dùng không nhìn thấy được), nên bố cục của người dùng sẽ không thay đổi.

Trong trường hợp không thể chèn nội dung mới kịp thời, nút "Hiện thêm" sẽ được hiển thị thay thế. Tuy nhiên, nút này chỉ được bật khi các mục mới đã sẵn sàng hiển thị. Điều này đảm bảo rằng người dùng không nhấp vào nút này chỉ để thấy rằng không có gì xảy ra. Do đó, bất kể máy chủ phản hồi chậm như thế nào với nội dung mới (hoặc người dùng cuộn nhanh đến mức nào), sẽ không bao giờ có bất kỳ thay đổi bố cục ngoài dự kiến nào.

Triển khai

API Quan sát giao tiếp là một cách hiệu quả để theo dõi vị trí và chế độ hiển thị của các phần tử trang. Thiết kế này được triển khai bằng cách sử dụng hai đối tượng tiếp nhận dữ liệu xen kẽ riêng biệt:

  • listObserver quan sát vị trí của #infinite-scroll-button nằm ở cuối danh sách cuộn vô hạn. Khi nút ở gần khung nhìn, nội dung chưa được chèn sẽ được thêm vào DOM.
  • sentinelObserver quan sát vị trí của phần tử #sentinel. Khi chuông hiển thị, máy chủ sẽ yêu cầu thêm nhiều nội dung. Điều chỉnh vị trí của người canh gác là một cách để kiểm soát khoảng thời gian cần thiết yêu cầu nội dung mới từ máy chủ.

Đây không phải là cách duy nhất để giải quyết việc thay đổi bố cục bắt nguồn từ việc sử dụng chức năng cuộn vô hạn. Các cách khác để tiếp cận vấn đề này bao gồm chuyển sang tính năng phân trang, sử dụng tính năng ảo hoá danh sách và điều chỉnh bố cục trang.

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