IntersectionObserver 實戰:懶載入、無限捲動與視窗檢測
性能优化(更新於 2026年6月2日)
IntersectionObserver:高效能視窗檢測 API
IntersectionObserver 是瀏覽器提供的非同步視窗交叉檢測 API,替代傳統的 scroll 事件監聽:
| 對比項 | scroll 監聽 | IntersectionObserver |
|---|---|---|
| 執行緒 | 主執行緒同步 | 瀏覽器內部非同步 |
| 觸發頻率 | 每幀都可能觸發 | 僅交叉狀態變化時 |
| 效能開銷 | 高(需手動 throttle) | 低(瀏覽器最佳化) |
| 計算方式 | getBoundingClientRect() |
合成執行緒計算 |
| 相容性 | 全瀏覽器 | 98%+ 全球覆蓋 |
核心 API 詳解
基本用法
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
console.log(entry.isIntersecting);
console.log(entry.intersectionRatio);
console.log(entry.target);
console.log(entry.boundingClientRect);
console.log(entry.rootBounds);
console.log(entry.time);
});
},
{
root: null,
rootMargin: '0px',
threshold: [0, 0.5, 1.0]
}
);
observer.observe(document.querySelector('.target'));
observer.unobserve(element);
observer.disconnect();
rootMargin:提前/延遲觸發
const lazyObserver = new IntersectionObserver(callback, {
rootMargin: '200px 0px'
});
const fullObserver = new IntersectionObserver(callback, {
rootMargin: '-100px 0px'
});
threshold:精確控制觸發時機
const observer = new IntersectionObserver(callback, {
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
});
const observer2 = new IntersectionObserver(callback, {
threshold: 1.0
});
實戰一:圖片懶載入
基礎實作
<img data-src="photo.webp" alt="描述" class="lazy-img" />
class LazyImageLoader {
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target as HTMLImageElement;
img.src = img.dataset.src!;
img.removeAttribute('data-src');
this.observer.unobserve(img);
}
});
},
{ rootMargin: '200px 0px' }
);
}
observe(container: HTMLElement) {
container.querySelectorAll<HTMLImageElement>('.lazy-img')
.forEach((img) => this.observer.observe(img));
}
}
漸進式載入(模糊 → 清晰)
class ProgressiveImageLoader {
private observer: IntersectionObserver;
constructor() {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target as HTMLImageElement;
const thumb = img.dataset.thumb!;
img.src = thumb;
img.classList.add('blurred');
const fullSrc = img.dataset.full!;
const fullImg = new Image();
fullImg.onload = () => {
img.src = fullSrc;
img.classList.remove('blurred');
};
fullImg.src = fullSrc;
this.observer.unobserve(img);
});
},
{ rootMargin: '300px 0px' }
);
}
}
.blurred {
filter: blur(20px);
transition: filter 0.3s ease;
}
實戰二:無限捲動
class InfiniteScroll {
private observer: IntersectionObserver;
private loading = false;
private page = 1;
constructor(
private container: HTMLElement,
private loadMore: (page: number) => Promise<HTMLElement[]>
) {
const sentinel = document.createElement('div');
sentinel.className = 'scroll-sentinel';
container.appendChild(sentinel);
this.observer = new IntersectionObserver(
async (entries) => {
if (!entries[0].isIntersecting || this.loading) return;
this.loading = true;
this.page++;
const items = await this.loadMore(this.page);
items.forEach((item) => container.insertBefore(item, sentinel));
this.loading = false;
},
{ rootMargin: '500px' }
);
this.observer.observe(sentinel);
}
destroy() {
this.observer.disconnect();
}
}
實戰三:虛擬捲動
class VirtualScroller<T> {
private observer: IntersectionObserver;
private visibleRange = { start: 0, end: 0 };
private itemHeight = 48;
constructor(
private container: HTMLElement,
private items: T[],
private renderItem: (item: T, index: number) => HTMLElement
) {
this.container.style.height = `${this.items.length * this.itemHeight}px`;
this.container.style.position = 'relative';
this.setupObserver();
}
private setupObserver() {
const sentinelTop = document.createElement('div');
const sentinelBottom = document.createElement('div');
this.container.prepend(sentinelTop);
this.container.appendChild(sentinelBottom);
this.observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
this.updateVisibleItems();
});
});
this.observer.observe(sentinelTop);
this.observer.observe(sentinelBottom);
}
private updateVisibleItems() {
const scrollTop = this.container.scrollTop;
const viewHeight = this.container.clientHeight;
const start = Math.floor(scrollTop / this.itemHeight);
const end = Math.min(
start + Math.ceil(viewHeight / this.itemHeight) + 2,
this.items.length
);
if (start === this.visibleRange.start && end === this.visibleRange.end) return;
this.visibleRange = { start, end };
this.render();
}
private render() {
const fragment = document.createDocumentFragment();
for (let i = this.visibleRange.start; i < this.visibleRange.end; i++) {
const el = this.renderItem(this.items[i], i);
el.style.position = 'absolute';
el.style.top = `${i * this.itemHeight}px`;
fragment.appendChild(el);
}
this.container.innerHTML = '';
this.container.appendChild(fragment);
}
}
實戰四:曝光打點
class ExposureTracker {
private observer: IntersectionObserver;
private tracked = new WeakSet<Element>();
constructor(private onExpose: (element: Element) => void) {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
if (this.tracked.has(entry.target)) return;
this.tracked.add(entry.target);
this.onExpose(entry.target);
});
},
{ threshold: 0.5 }
);
}
track(elements: Element[]) {
elements.forEach((el) => this.observer.observe(el));
}
}
const tracker = new ExposureTracker((el) => {
const id = el.getAttribute('data-track-id');
analytics.track('element_exposed', { id });
});
效能對比
| 場景 | scroll 監聽 | IntersectionObserver | 提升 |
|---|---|---|---|
| 1000 圖片懶載入 | ~60fps(throttle 200ms) | ~60fps(無抖動) | 流暢度 ↑ |
| 捲動事件觸發次數 | ~300 次/秒 | ~5 次/秒 | 60× ↓ |
| getBoundingClientRect | 1000 次/觸發 | 0(瀏覽器計算) | 100% ↓ |
| 主執行緒占用 | 15-30ms/幀 | <1ms/回呼 | 30× ↓ |
瀏覽器相容性
| 瀏覽器 | 版本 | 備註 |
|---|---|---|
| Chrome | 51+ | 完整支援 |
| Firefox | 55+ | 完整支援 |
| Safari | 12.1+ | 完整支援 |
| Edge | 15+ | 完整支援 |
| IE | 不支援 | 需 polyfill |
if ('IntersectionObserver' in window) {
// 使用 IntersectionObserver
} else {
document.querySelectorAll('.lazy-img').forEach((img) => {
(img as HTMLImageElement).src = (img as HTMLImageElement).dataset.src!;
});
}
最佳實踐
- 及時 unobserve:元素載入後立即取消觀察,減少回呼觸發
- 合理 rootMargin:懶載入用
200-500px,曝光打點用0px - 批量觀察:建立一個 Observer 實例觀察多個元素
- threshold 選擇:懶載入
[0],進度條[0, 0.25, 0.5, 0.75, 1] - disconnect 清理:元件卸載時呼叫
disconnect()避免記憶體洩漏
總結
IntersectionObserver 是現代前端視窗檢測的標準方案,相比 scroll 監聯有數量級的效能優勢。懶載入、無限捲動、曝光打點、動畫觸發是其四大核心場景,搭配 rootMargin 和 threshold 可實現精細控制。
本站提供瀏覽器本地工具,免註冊即可試用 →
#IntersectionObserver#懒加载#虚拟滚动#性能#视口检测