iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
Modern Web

從實務需求出發:前端視覺與 UI 互動開發實踐系列 第 27 篇

【Day 27】網頁互動:滾動進場 IntersectionObserver 的實作與重構

  • 分享至 

  • xImage
  •  

之前專案的首頁有好幾個區塊,都是捲到畫面才慢慢浮現。這部分不是我寫的,我接手時很好奇是怎麼做到的,才知道有 IntersectionObserver 這個好用的 Web API。不過當時的寫法有些坑,所以趁這次來好好釐清觀念。

今天會分三個部分,先來認識 IntersectionObserver,再比較它和 scroll 事件(視差)的差別,最後補充如何把重複的寫法收斂成一個 composable,以及那個非同步資料的坑。今天的範例也都是用 Vue 寫的,那麼我們開始吧!


一、IntersectionObserver 是什麼

要知道一個元素有沒有被使用者看到,傳統做法是監聽 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。


二、scroll 事件 vs. observer

之前聽到隨著滾動產生畫面變化,第一個想到的是視差滾動。一開始把它跟 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!

純 CSS 的選擇: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。


三、進一步優化

重複的地方打包成 composable

目前單一頁面有 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
})

導致問題的流程如下:

  1. 元件掛載時,API 資料還沒回來,v-if 為 false,<section> 沒被建立,rankEl 是 null。
  2. onMounted 只會綁定一次 observer,而 rankEl.value && ... 遇到 null 就直接跳過,沒有報錯。
  3. 資料回來後,<section> 才出現,但已經沒有人去觀察它了。
  4. 加上這個區塊的 CSS 預設是 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)


完整範例

Codepen 連結

💡 範例裡每個 observer 都多了一行 root: document。因為 CodePen 這類線上編輯器是把預覽放在跨網域的 iframe 裡。不指定 root 時,rootMargin 在這種 iframe 裡不會生效。

.
.
.
Day27 Clear!倒數剩下 3 天! ᕕ ( ᐛ ) ᕗ ᕕ( ᐕ )ᕗ


參考資料


上一篇
【Day 26】網頁互動:列表排序變動加上 FLIP 動畫
下一篇
【Day 28】動畫實作:Lottie 動畫優化,從手寫到元件封裝
系列文
從實務需求出發:前端視覺與 UI 互動開發實踐 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言