之前專案的首頁有好幾個區塊,都是捲到畫面才慢慢浮現。這部分不是我寫的,我接手時很好奇是怎麼做到的,才知道有 IntersectionObserver 這個好用的 Web API。不過當時的寫法有些坑,所以趁這次來好好釐清觀念。
今天會分三個部分,先來認識 IntersectionObserver,再比較它和 scroll 事件(視差)的差別,最後補充如何把重複的寫法收斂成一個 composable,以及那個非同步資料的坑。今天的範例也都是用 Vue 寫的,那麼我們開始吧!
要知道一個元素有沒有被使用者看到,傳統做法是監聽 scroll 事件,每次捲動都呼叫 getBoundingClientRect() 計算位置。問題是 scroll 觸發得非常頻繁,這些計算又都跑在主執行緒上,頁面容易變卡。
IntersectionObserver 是瀏覽器內建的 API,將監聽工作交由瀏覽器,當元素和畫面「相交」的狀態改變時才通知你。有了 IntersectionObserver,網站就不用再自己在主執行緒上做這件事,交給瀏覽器去處理。
💡 推薦 這篇文章,用便利商店來比喻:
onscroll像是一直盯著門口的店員,IntersectionObserver 則像自動門,有人進來才會叮咚一聲通知你。
const observer = new IntersectionObserver((entries) => {
const entry = entries[0]
if (entry.isIntersecting) {
// 元素進入畫面了
}
}, {
threshold: 0.1,
rootMargin: '0px 0px -20% 0px',
})
observer.observe(element) // 開始觀察
observer.unobserve(element) // 停止觀察某個元素
observer.disconnect() // 停止觀察所有元素
callback 收到的 entries 是陣列,每個 entry 常用的欄位有:
isIntersecting:現在是否和觀察範圍相交。intersectionRatio:相交的比例。target:被觀察的元素。💡 依照 MDN,除了元素進出相交範圍時會呼叫 callback,「observer 第一次被要求觀察某個元素時」也會呼叫一次。
| 選項 | 作用 | 預設 |
|---|---|---|
root |
觀察範圍的基準,可以指定某個捲動容器 | 瀏覽器視窗 |
threshold |
元素露出多少比例時觸發(0~1,也可以傳陣列) | 0,只要碰到就觸發 |
rootMargin |
把觀察範圍往外放大或往內縮小,寫法類似 CSS margin(上右下左,單位 px 或 %) | 0px |
rootMargin 是進場動畫最常調整的選項。正值往外放大,可以用來提早載入圖片;負值則往內縮小,我們這次會用到:
{ rootMargin: '0px 0px -20% 0px' }
意思是把視窗的底部往內縮 20%,元素要進到「畫面底部往上 20%」以內,才算進入。如果不內縮,元素剛從底部露出一條邊,動畫就開始播了,使用者看不到動畫播放過程。往內縮之後,動畫會等到元素比較靠近畫面中間才開始。
流程只有幾步:進入畫面 → isIn 設為 true → 模板加上 is-in class → CSS 的 transition 負責動畫。
<template>
<div ref="cardEl" class="card" :class="{ 'is-in': isIn }">
拿鐵小站
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const cardEl = ref(null)
const isIn = ref(false)
let observer = null
onMounted(() => {
observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return
isIn.value = true
observer.disconnect() // 該動畫只播一次,進入後就不用再觀察
}, { threshold: 0.1, rootMargin: '0px 0px -20% 0px' })
observer.observe(cardEl.value)
})
onBeforeUnmount(() => observer?.disconnect())
</script>
<style>
.card {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease, transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.card.is-in {
opacity: 1;
transform: none;
}
</style>
💡 JS 只負責決定「什麼時候」加上
is-in,動畫本身完全交給 CSS。之後想調整動畫的速度或樣式,只要改 CSS,不用動 JS。
之前聽到隨著滾動產生畫面變化,第一個想到的是視差滾動。一開始把它跟 observer 搞混了,但其實它們是針對兩種不同的畫面需求:
| 視差 | 進場 | |
|---|---|---|
| 需求 | 位置隨捲動連續變化 | 進入畫面時觸發一次 |
| 工具 | scroll 監聽 + rAF |
IntersectionObserver |
| JS 做什麼 | 每一幀計算進度,寫入 CSS 變數 | 加一個 class |
| 動畫由誰負責 | JS 計算每一幀的值 | CSS transition |
| 是否可停止 | 要自己管理 | 進入後 disconnect() |
進場動畫只需要知道元素進來了沒,所以交給 observer 就好。但視差需要的是「現在捲到哪了」這種連續的資訊,observer 只會在跨過門檻時通知,所以還是得靠 scroll 事件。
專案裡的視差滾動,是等元素進入畫面之後才開始監聽 scroll,用 observer 當作開關。不過,還有能再調整的空間。目前 scroll 事件監聽掛上去之後,直到離開頁面才會移除。元素捲出畫面之後,每一幀還是會呼叫 getBoundingClientRect(),在沒有人看到的地方持續計算。
我們把它反過來,observer 不 disconnect(),進入畫面時掛上監聽,離開時移除:
const gate = new IntersectionObserver(([entry]) => {
entry.isIntersecting ? attach() : detach() // attach() 是掛上監聽;detach() 是移除監聽的函式
})
gate.observe(stageEl.value)
這樣視差只在「使用者看得到的時候」才會計算。Nice!
animation-timeline其實這類捲動的效果,CSS 現在也有原生的做法:animation-timeline。它讓動畫的進度由「捲動」決定,而不是時間。常用的有兩種:
scroll():依整個捲動容器的捲動進度。view():依元素在畫面中出現的進度,很適合用來做進場。@keyframes reveal {
0% { opacity: 0; transform: translateY(24px); }
100% { opacity: 1; transform: none; }
}
.card {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 0% entry 60%; /* 只在進入畫面的前 60% 播放 */
}
不需要 JS,也不用 observer。可惜的是支援度不夠完整,2026 年 10 月查 Can I use ,看到 Chrome 與 Edge 115 以上、Safari(含 iOS)26 以上,Firefox 目前尚未支援(只有 160 的 Nightly 版有)。
💡 想深入了解
scroll()、view()、animation-range的各種寫法,可以看這篇前端開發筆記:Scroll-driven Animations。
目前單一頁面有 4 個進場元素,每個元素都寫了一整套:一個 ref、一個 isXxx、一個 let xxxIO,在 onMounted 建立 observer,在 onBeforeUnmount 清理。其他頁也有幾乎相同的副本,全專案共 10 個手寫 observer。差別只在「觀察哪個元素」和「threshold、rootMargin 的數值」。在 Vue 中,可以把這些程式碼收斂成一個 composable:
// composable/dom/useInView.js
import { ref, watch, onBeforeUnmount } from 'vue'
// 目標元素(targetRef)首次進入畫面後即停止觀察,支援 v-if 延後渲染的元素
export const useInView = (targetRef, options = {}) => {
const isInView = ref(false)
let observer = null
const stop = () => {
observer?.disconnect()
observer = null
}
watch(targetRef, (el) => {
stop()
if (!el || isInView.value) return
observer = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return
isInView.value = true
stop()
}, options)
observer.observe(el)
}, { flush: 'post' })
onBeforeUnmount(stop)
return { isInView }
}
使用時,就只需要引用 composable,再傳入要做進場的 DOM 與客製化選項:
<script setup>
import { ref } from 'vue'
import { useInView } from '@/composable/dom/useInView'
const rankEl = ref(null)
const { isInView: isRankIn } = useInView(rankEl, {
threshold: 0.3,
rootMargin: '0px 0px -20% 0px',
})
</script>
<template>
<section ref="rankEl" :class="{ 'is-in': isRankIn }">...</section>
</template>
這個 composable 做了三件事:首次進入後就停止觀察、元件卸載時自動清理、監聽 ref 的變化。尤其是「監聽 ref」這件事,看起來不起眼,卻能避開下面要介紹的坑。
排行榜區塊原本長這樣:
<section v-if="rankList.length" ref="rankEl" :class="{ 'is-in': isRankIn }">
...
</section>
開發初期是先在前端專案寫假資料,看起來一切正常。直到把它改成 API 回傳資料後,排行榜區塊從頭到尾一片空白。沒有錯誤、Network 的請求也成功,找了一陣子才發現問題點。
原本的 observer 是這樣綁的:
onMounted(() => {
rankIO = new IntersectionObserver(/* ... */)
rankEl.value && rankIO.observe(rankEl.value) // 資料還沒回來時,這裡是 null
})
導致問題的流程如下:
v-if 為 false,<section> 沒被建立,rankEl 是 null。onMounted 只會綁定一次 observer,而 rankEl.value && ... 遇到 null 就直接跳過,沒有報錯。<section> 才出現,但已經沒有人去觀察它了。opacity: 0,沒有 .is-in 就永遠不會顯示。所以才需要改成 watch ref 本身。因為資料是非同步的,onMounted 階段綁不到元素,在前面的 composable 才會用 watch 加 flush: 'post':
watch(targetRef, (el) => {
// ref 從 null 變成元素時,這裡就會被呼叫
}, { flush: 'post' })
另外補充一點,composable 的監聽中之所以要先 stop() 一次,是避免元素被替換(例如 v-if 切換)時,不會有舊的 observer 殘留在背景。
💡 在 Vue 中使用 watch 監聽響應式資料時預設是
flush: 'pre',會在 DOM 更新前執行 callback;若設成'post'則是在 DOM 更新後執行。另外還可以設為'sync',會變成每次資料改變都會觸發。(參考 Vue 文件、Vue3 watch、watchEffect怎麼用(下) | YangLab)
💡 範例裡每個 observer 都多了一行
root: document。因為 CodePen 這類線上編輯器是把預覽放在跨網域的 iframe 裡。不指定root時,rootMargin在這種 iframe 裡不會生效。
.
.
.
Day27 Clear!倒數剩下 3 天! ᕕ ( ᐛ ) ᕗ ᕕ( ᐕ )ᕗ