元素交叉觀察 (Intersection Observer)
在網頁開發中,偵測「元素是否進入視窗」是一個非常經典的需求。過去我們習慣監聽 scroll 事件,並不斷計算 getBoundingClientRect().top,這不僅程式碼繁瑣,頻繁的 DOM 計算更是效能殺手。
Intersection Observer API 的出現徹底改變了這一切。它提供這了一種非同步、高效能的方式來觀察元素與視窗(或特定容器)的交集變化。
本篇將帶你深入了解這個強大的 API,並實作一個靈活的 Vue 3 useIntersectionObserver 組合函式。
一、 為什麼需要 Intersection Observer?
傳統 Scroll 監聽的問題
javascript
// ❌ 效能不佳的舊做法
window.addEventListener("scroll", () => {
const rect = element.getBoundingClientRect();
// 頻繁觸發 Layout 回流 (Reflow),導致頁面卡頓
if (rect.top < window.innerHeight) {
loadMore();
}
});Intersection Observer 的優勢
- 非同步執行:不會阻塞主執行緒。
- 效能優化:瀏覽器內部優化了交集計算,不需開發者手動計算座標。
- API 簡潔:只需設定回呼函式 (Callback),當狀態改變時會自動觸發。
二、 API 核心概念
2.1 建立觀察者
javascript
const observer = new IntersectionObserver(callback, options);
observer.observe(element); // 開始觀察目標元素2.2 設定選項 (Options)
| 屬性 | 說明 | 預設值 |
|---|---|---|
root | 視窗容器,設為 null 代表瀏覽器視窗 (Viewport) | null |
rootMargin | 擴大或縮小觸發邊界(類似 CSS margin),如 '0px 0px 200px 0px' 可提早觸發 | '0px' |
threshold | 觸發門檻(0~1),0 代表剛出現就觸發,1 代表完全出現才觸發 | 0 |
三、 設計 Composable 介面
我們希望 useIntersectionObserver 能應用於兩種情境:
- 簡單狀態:只需知道
isIntersecting(例如 CSS 動畫)。 - 複雜邏輯:需要執行特定動作 (例如載入更多資料)。
typescript
// types/useIntersectionObserver.ts
import type { Ref } from "vue";
export interface UseIntersectionObserverOptions {
root?: Ref<HTMLElement | null> | null;
rootMargin?: string;
threshold?: number | number[];
/** 是否只觸發一次(常見於進場動畫) */
once?: boolean;
}
export interface UseIntersectionObserverReturn {
isIntersecting: Ref<boolean>;
stop: () => void;
}四、 實作 useIntersectionObserver Composable
4.1 完整實作
typescript
// composables/useIntersectionObserver.ts
import { ref, onMounted, onUnmounted, watch } from "vue";
import type { Ref } from "vue";
import type {
UseIntersectionObserverOptions,
UseIntersectionObserverReturn,
} from "@/types/useIntersectionObserver";
export function useIntersectionObserver(
target: Ref<HTMLElement | null>,
options: UseIntersectionObserverOptions = {},
): UseIntersectionObserverReturn {
const {
root = null,
rootMargin = "0px",
threshold = 0,
once = false,
} = options;
const isIntersecting = ref(false);
let observer: IntersectionObserver | null = null;
// 清理函式
const stop = () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
// 啟動觀察
const start = () => {
stop(); // 避免重複建立
if (!target.value) return;
if (typeof IntersectionObserver === "undefined") return; // 支援度檢查
const rootEl = root ? root.value : null;
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
isIntersecting.value = entry.isIntersecting;
// 如果設定 once 且已進入視窗,則停止觀察
if (once && entry.isIntersecting) {
stop();
}
},
{
root: rootEl,
rootMargin,
threshold,
},
);
observer.observe(target.value);
};
// 當 target 有變化時(例如 v-if 切換),重新啟動觀察
watch(target, () => {
if (target.value) start();
else stop();
});
onMounted(start);
onUnmounted(stop);
return {
isIntersecting,
stop,
};
}五、 實際應用範例
5.1 圖片懶加載 (Lazy Loading)
當圖片進入視窗時才載入真實連結,節省不必要的頻寬。
vue
<script setup lang="ts">
import { ref, watch } from "vue";
import { useIntersectionObserver } from "@/composables/useIntersectionObserver";
const imgRef = ref<HTMLImageElement | null>(null);
const src = "https://picsum.photos/600/400";
const loadedSrc = ref(""); // 初始為空
const { isIntersecting } = useIntersectionObserver(imgRef, {
once: true, // 只需觸發一次
});
watch(isIntersecting, (val) => {
if (val) {
loadedSrc.value = src;
}
});
</script>
<template>
<div class="image-placeholder">
<img
ref="imgRef"
:src="loadedSrc"
alt="Lazy loaded image"
:class="{ loaded: isIntersecting }"
/>
</div>
</template>
<style scoped>
.image-placeholder {
min-height: 400px;
background: #eee;
}
img {
opacity: 0;
transition: opacity 0.5s;
}
img.loaded {
opacity: 1;
}
</style>5.2 無限滾動 (Infinite Scroll)
這是最實用的場景之一。我們在列表底部放置一個「偵測點 (Trigger)」,當它進入視窗時,自動載入下一頁資料。
vue
<script setup lang="ts">
import { ref, watch } from "vue";
import { useIntersectionObserver } from "@/composables/useIntersectionObserver";
const list = ref<number[]>([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const loadMoreTrigger = ref<HTMLElement | null>(null);
const isLoading = ref(false);
const { isIntersecting } = useIntersectionObserver(loadMoreTrigger, {
rootMargin: "100px", // 提早 100px 觸發,讓使用者無感載入
});
// 當觸發點進入視窗且不在載入中 => 載入更多
watch(isIntersecting, async (val) => {
if (val && !isLoading.value) {
isLoading.value = true;
console.log("載入更多資料...");
// 模擬 API 請求
await new Promise((resolve) => setTimeout(resolve, 1000));
// 追加資料
const last = list.value[list.value.length - 1];
for (let i = 1; i <= 5; i++) {
list.value.push(last + i);
}
isLoading.value = false;
}
});
</script>
<template>
<div class="list">
<div v-for="item in list" :key="item" class="item">Item {{ item }}</div>
<!-- 偵測觸發點 -->
<div ref="loadMoreTrigger" class="trigger">
{{ isLoading ? "載入中..." : "載入更多" }}
</div>
</div>
</template>總結
| 概念 | 說明 |
|---|---|
| IntersectionObserver | 用於觀察元素與視窗交集的 API |
| threshold | 決定觸發回呼的「可見比例」,0 為一碰到就觸發 |
| rootMargin | 調整觸發邊界,常用於「預先載入」 |
| once | 實作懶加載或進場動畫時,通常只需要觸發一次 |
TIP
最佳實踐:在使用無限滾動時,建議設定 rootMargin 為正值(例如 200px),讓使用者快滾到底部之前就開始載入,營造「無縫」的瀏覽體驗。
進階挑戰
- 影片自動播放:使用
threshold: 0.5,當影片區塊超過 50% 可見時自動播放,移出時自動暫停。 - 導航列高亮:偵測各個章節標題 (
h2) 是否進入視窗,自動更新側邊導航列的當前章節。 - 虛擬列表 (Virtual Scroller):結合
IntersectionObserver實作高效能長列表,只渲染視窗內的 DOM 元素。