這種無限捲動實作可確保無論伺服器回應新內容需要多久時間,確保不會發生任何版面配置位移。
實作無限捲動最常見的問題之一,就是每當新增項目時,頁面頁尾 (或類似的使用者體驗元素) 就會往下推到頁面下方。採用這種無限捲動功能後,就不會發生這種情形。
高階方法
盡可能在新項目觸及使用者之前,盡可能插入網頁。由於這項插入作業是在畫面外進行 (使用者看不到),因此使用者不會經歷版面配置位移。
如果系統無法及時插入新內容,會改為顯示「顯示更多」按鈕。不過,只有在新項目準備好顯示時才會啟用該按鈕,如此可確保使用者不會只點按該按鈕,才會發現任何內容都不會有任何影響。因此,無論伺服器回應新內容的速度 (或使用者捲動的速度) 有多慢,絕對不會出現任何非預期的版面配置位移。
導入方法
Intersection Observer API 是監控網頁元素位置和瀏覽權限的有效方法。這項設計使用兩個獨立的干擾觀察器實作:
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();