跳至主要內容
Skip to content

元素交叉觀察 (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 的優勢

  1. 非同步執行:不會阻塞主執行緒。
  2. 效能優化:瀏覽器內部優化了交集計算,不需開發者手動計算座標。
  3. 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 能應用於兩種情境:

  1. 簡單狀態:只需知道 isIntersecting (例如 CSS 動畫)。
  2. 複雜邏輯:需要執行特定動作 (例如載入更多資料)。
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),讓使用者快滾到底部之前就開始載入,營造「無縫」的瀏覽體驗。


進階挑戰

  1. 影片自動播放:使用 threshold: 0.5,當影片區塊超過 50% 可見時自動播放,移出時自動暫停。
  2. 導航列高亮:偵測各個章節標題 (h2) 是否進入視窗,自動更新側邊導航列的當前章節。
  3. 虛擬列表 (Virtual Scroller):結合 IntersectionObserver 實作高效能長列表,只渲染視窗內的 DOM 元素。

延伸閱讀與資源