瀑布流無限滾動的核心是「檢測用戶滾動到底部」。傳統 scroll 事件頻繁觸發、性能差;現代 IntersectionObserver API 異步觀察、零性能損耗。本文對比兩種方案並提供生產級代碼。
<script>
// 防抖節流工具
function debounce(fn, delay) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
function throttle(fn, limit) {
let inThrottle = false;
return (...args) => {
if (!inThrottle) {
fn.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
class ScrollLoader {
constructor(options = {}) {
this.container = options.container || window;
this.triggerEl = options.triggerEl; // 底部哨兵元素
this.callback = options.callback;
this.threshold = options.threshold || 300; // px
this.debounceMs = options.debounceMs || 100;
this.boundCheck = debounce(this.check.bind(this), this.debounceMs);
this.bind();
}
bind() {
if (this.triggerEl) {
// 哨兵模式:監聽哨兵元素是否進入可視區
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.loading) {
this.loadMore();
}
});
}, {rootMargin: '200px'}); // 提前 200px 觸發
this.observer.observe(this.triggerEl);
} else {
// 傳統 scroll 監聽
$(this.container).on('scroll', this.boundCheck);
}
}
check() {
const scrollTop = this.container === window ? $(window).scrollTop() : $(this.container).scrollTop();
const windowHeight = this.container === window ? $(window).height() : $(this.container).height();
const docHeight = this.container === window ? $(document).height() : $(this.container)[0].scrollHeight;
if (scrollTop + windowHeight >= docHeight - this.threshold) {
this.loadMore();
}
}
async loadMore() {
if (this.loading) return;
this.loading = true;
try {
await this.callback();
} finally {
this.loading = false;
}
}
destroy() {
if (this.observer) this.observer.disconnect();
$(this.container).off('scroll', this.boundCheck);
}
}
// 使用
const loader = new ScrollLoader({
triggerEl: document.getElementById('sentinel'), // 推薦:哨兵元素
callback: async () => {
const data = await fetchNextPage();
renderItems(data);
}
});
</script>
<script>
class IntersectionLoader {
constructor(options = {}) {
this.sentinel = options.sentinel; // 必須:底部哨兵元素
this.callback = options.callback; // 加載回調
this.rootMargin = options.rootMargin || '200px 0px'; // 提前觸發距離
this.threshold = options.threshold || 0;
this.loading = false;
this.observer = null;
if (!this.sentinel) {
throw new Error('IntersectionLoader 需要 sentinel 元素');
}
this.init();
}
init() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !this.loading) {
this.loadMore();
}
});
}, {
root: null, // viewport
rootMargin: this.rootMargin,
threshold: this.threshold,
});
this.observer.observe(this.sentinel);
}
async loadMore() {
if (this.loading) return;
this.loading = true;
this.showLoading(true);
try {
const hasMore = await this.callback();
if (!hasMore) this.disconnect();
} catch (e) {
console.error('加載失敗:', e);
this.showError(e.message);
} finally {
this.loading = false;
this.showLoading(false);
}
}
showLoading(show) {
const loader = document.getElementById('loading-indicator');
if (loader) loader.style.display = show ? 'block' : 'none';
}
showError(msg) {
// 顯示錯誤提示,提供重試按鈕
}
disconnect() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
// 手動觸發(如用戶點擊「加載更多」)
trigger() {
this.loadMore();
}
}
// HTML 結構
/*
*/
// 使用
const loader = new IntersectionLoader({
sentinel: document.getElementById('sentinel'),
rootMargin: '300px 0px', // 提前 300px 預加載
callback: async () => {
const page = window.__page || 1;
const res = await fetch(`/api/articles?page=${page + 1}`);
const data = await res.json();
if (data.items?.length) {
renderItems(data.items);
window.__page = page + 1;
return data.pagination?.hasMore ?? true;
}
return false; // 無更多數據
}
});
</script>
<script>
class WaterfallInfiniteScroll {
constructor(options = {}) {
this.container = options.container; // 容器元素
this.itemSelector = options.itemSelector || '.waterfall-item';
this.columnWidth = options.columnWidth || 360;
this.gap = options.gap || 15;
this.apiUrl = options.apiUrl;
this.page = 1;
this.hasMore = true;
this.loading = false;
this.sentinel = document.createElement('div');
this.sentinel.id = 'waterfall-sentinel';
this.sentinel.style.cssText = 'height:1px;';
this.container.appendChild(this.sentinel);
this.loader = new IntersectionLoader({
sentinel: this.sentinel,
rootMargin: '300px 0px',
callback: () => this.loadPage(),
});
this.layout();
window.addEventListener('resize', this.debounce(() => this.layout(), 200));
}
async loadPage() {
if (this.loading || !this.hasMore) return false;
this.loading = true;
try {
const res = await fetch(`${this.apiUrl}?page=${this.page + 1}`);
const data = await res.json();
if (data.items?.length) {
this.appendItems(data.items);
this.page++;
this.hasMore = data.pagination?.hasMore ?? true;
return this.hasMore;
}
this.hasMore = false;
return false;
} catch (e) {
console.error('加載失敗:', e);
throw e;
} finally {
this.loading = false;
}
}
appendItems(items) {
const fragment = document.createDocumentFragment();
items.forEach(item => {
const div = document.createElement('div');
div.className = 'waterfall-item';
div.innerHTML = this.renderCard(item);
fragment.appendChild(div);
});
this.container.insertBefore(fragment, this.sentinel);
this.layout();
}
renderCard(item) {
return `
${item.title}
${item.content}
`;
}
layout() {
const containerWidth = this.container.clientWidth;
const cols = Math.max(1, Math.floor((containerWidth + this.gap) / (this.columnWidth + this.gap)));
const colHeights = new Array(cols).fill(0);
const colLefts = Array.from({length: cols}, (_, i) => i * (this.columnWidth + this.gap));
const items = this.container.querySelectorAll(this.itemSelector);
items.forEach(item => {
if (item === this.sentinel) return;
const height = item.offsetHeight;
const minCol = colHeights.indexOf(Math.min(...colHeights));
item.style.position = 'absolute';
item.style.left = colLefts[minCol] + 'px';
item.style.top = colHeights[minCol] + 'px';
item.style.width = this.columnWidth + 'px';
colHeights[minCol] += height + this.gap;
});
this.container.style.height = Math.max(...colHeights) + 'px';
}
debounce(fn, delay) {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
}
// 初始化
document.addEventListener('DOMContentLoaded', () => {
new WaterfallInfiniteScroll({
container: document.getElementById('waterfall'),
apiUrl: '/api/articles',
columnWidth: 360,
gap: 15,
});
});
</script>
| 維度 | Scroll + 防抖 | IntersectionObserver |
|---|---|---|
| 性能 | 高頻觸發,主線程繁忙 | 異步觀察,零主線程開銷 |
| 準確性 | 需手動計算閾值 | 瀏覽器原生精準檢測 |
| 預加載 | 靠 threshold 控制 | rootMargin 靈活配置 |
| 兼容性 | IE6+ 全支援 | IE 不支援,需 Polyfill |
| 代碼複雜度 | 低 | 中(需哨兵元素) |
| 移動端體驗 | 滾動卡頓明顯 | 絲滑流暢 |
<!-- 在 head 中引入 -->
<script src="https://cdn.jsdelivr.net/npm/intersection-observer@0.12.2/intersection-observer.min.js"></script>
<script>
// 檢測並加載 Polyfill
if (!('IntersectionObserver' in window)) {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/intersection-observer@0.12.2/intersection-observer.min.js';
script.onload = () => initWaterfall();
document.head.appendChild(script);
} else {
initWaterfall();
}
</script>
translate3d 位置loading="lazy" + IntersectionObserver 降級sessionStorage 保存頁碼/滾動位置,刷新恢復history.pushState 更新 URL現代瀏覽器環境下,IntersectionObserver + 哨兵元素 是實現瀑布流無限滾動的最佳方案,性能優越、代碼優雅、體驗流暢。