Implement Infinte Scroll feat. Vue / Intersection Observer API

三步驟了解並實踐無限滾動加載 feat.Vue / Intersection Observer API

前言

近期工作剛好接觸很多介面開發的部分,其中一個需求實作「無限滾動加載功能」,這是一種常見的加載策略出現在 Facebook、Instagram、Twitter、Pinterest……等主流網站,當使用者滾動到頁面底部時會自動加載更多的資料,不用點擊按鈕就能瀏覽更多的資料,就像是自動版的加載按鈕。

頁籤、無限滑動、加載按鈕範例

工作中使用的是 Options API 不過這次範例會以 Composition API 來實作,同樣的邏輯也可以套用在任何程式或框架上。

實作

既然叫做滾動「加載」就意味著通常會與第三方索取資料,不過在範例中直接簡化此處的流程,撰寫一個回傳假資料的函式即可,這樣 getCards 函數就可以透過輸入 每頁資料數目前頁數 來產生出對應的卡片資料。

/**
* @param {number} limitsPerPage 每頁資料數
* @param {number} currentPage 當前頁數
* @returns {Array} 假卡片資料
*/
function getCards(limitsPerPage, currentPage) {
return Array.from({ length: limitsPerPage }, () => ({ title: currentPage.toString() }));
}
getCards(3, 2); // [{ title: '2' }, { title: '2' }, { title: '2' }]

定義問題與執行流程

無限加載不外乎就是自動偵測用戶是否滾動到「無限加載元件」之底部,並觸發加載的動作,於是可以將需求較為詳細定義為以下幾點:

  1. 如載入後元件仍在視線範圍內,持續載入到視線範圍外
  2. 如載入後元件在視線範圍外,停止載入
  3. 超過 x 頁就完全停止載入

為了知道目標元件是否在視線範圍內,可以製作一個通用的元件來偵測並發送事件,也就是接下來要製作的 Observer.vue 元件。

第一步:製作 Observer.vue 偵測元件

傳統要得知 DOM 元素的位置早期通常會使用 getBoundingClientRect() 來取得,不過基於效能、閱讀性以及 API 用途的考量,使用 Intersection Observer API 會是更好的選擇。

Intersection Observer API 自 2019 已經被各大瀏覽器款廣泛支援🔗,其用途主要監測 DOM 元素是否進入或離開另一個 DOM 元素或瀏覽器的視窗範圍內,並且可以透過設定門檻值來觸發對應的事件。

透過埋設一個無內容無樣式的元素在清單的底部並設定:「當與視窗關係發生變化時(離開/進入)」就觸發 Vue 自定義事件執行相關代碼片段。具體來說包裝好的 Observer.vue 元件如下:

<script setup>
import { ref, onMounted, onBeforeUnmount, defineProps, defineEmits } from 'vue';
const { observerOptions } = defineProps(['observerOptions']);
const emit = defineEmits(['is-in-view', 'is-outside-view']);
const target = ref(null);
const observer = ref(null);
const emitInView = () => {
emit('is-in-view');
};
const emitOutsideView = () => {
emit('is-outside-view');
};
onMounted(() => {
observer.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
emitInView();
} else {
emitOutsideView();
}
}, observerOptions);
observer.value.observe(target.value);
});
onBeforeUnmount(() => {
console.log('distory');
observer.value.disconnect();
});
</script>
<template>
<div ref="target" class="target"></div>
</template>

程式與概念很簡單,就是透過空 <div> 元素掛載時創建新的 IntersectionObserver 實例,觸發相對應的事件並且在元件銷毀時移除 IntersectionObserver 實例。並且將這個元件引入在無限加載清單底部,這樣就可以透過 @is-in-view 事件來觸發加載的動作。

<template>
<ul class="cards"></ul>
<Observer
v-if="!(infinteScrollOptions.currentPage > infinteScrollOptions.maxPage)"
@is-in-view="handleIsInView"
@is-outside-view="handleIsOutsideView"
/>
</template>

第二步:設定必備參數

一個無限加載的清單必備的狀態有,可以在開頭初始化定義起來:

  • maxPage 最大頁數
  • limitsPerPage 每頁資料數
  • currentPage 當前頁數
  • isInView 是否在視線範圍內
const infinteScrollOptions = {
maxPage: 10,
limitsPerPage: 1,
currentPage: 1,
isInView: false
}

並且創建第一批響應式資料,這裡我在卡片中塞入了隨機的 picsum🔗 圖片並且根據 index 作為圖片的 ID(請隨意塞任何你想呈現的資料):

<script setup>
import { ref } from "vue"
const cards = ref(getCards(infinteScrollOptions.limitsPerPage, infinteScrollOptions.currentPage))
<script>
<template>
<ul class="cards">
<li class="card" v-for="(card, index) in cards">
<img :src="`https://picsum.photos/id/${index}/300/300`" width="300" height="300" alt="Random Image">
<p>
#Image: {{ index }}
</p>
</li>
</ul>
<template>

第三步:無限加載邏輯

接著就是在 Observer.vue 觸發事件時透過切換 isInView 的狀態來決定是否繼續執行加載動作,以及只有在 Observer 元件存在於視線中時才會主動觸發加載。

加載過程中特別使用了 lodashthrottle 節流函式來控制加載頻率最大 300 毫秒才能觸發一次,是為了:

  1. 避免加載的內容還沒渲染上畫面,導致瘋狂觸發清單仍未加載滿而反覆加載問題
  2. 避免用戶頻繁滾動時過度觸發加載請求導致效能問題

關於節流,這裡是我寫過額外的介紹文章:從動圖輕鬆解題:防抖與節流 ,有動畫可以參考看看實際效果。

import { throttle } from 'lodash';
function handleIsInView() {
infinteScrollOptions.isInView = true;
handleLoadmore();
}
function handleIsOutsideView() {
infinteScrollOptions.isInView = false;
}
const handleLoadmore = throttle(
function (options = infinteScrollOptions) {
const { limitsPerPage, currentPage, isInView, maxPage } = options;
if (currentPage > maxPage) return;
const newCurrentPage = currentPage + 1;
infinteScrollOptions.currentPage = newCurrentPage;
const newCards = getCards(limitsPerPage, newCurrentPage);
cards.value = [...cards.value, ...newCards];
if (isInView) {
handleLoadmore();
}
},
300,
{ leading: true, trailing: true },
);

結語

現在已完成基本的無限加載功能,過程一些加載中、加載失敗的狀態可以根據需求自行調整,這裡就不多做介紹。範例程式可以在 GitHub 上下載🔗

這是一個很實在的練習題目,特別是在製作更進階的「無限滾動搜尋功能」時碰到了非常多的狀態需要管理,有空或許我會再寫一篇進階版本的文章,並描述我怎麼推導出最終成果。

程式結果呈現

如果你有更好的想法或是發現任何問題,歡迎在下方留言討論,我會非常的有興趣嘗試或討論看看不同的設計方式,像是:更為 Immutable 的寫法、有限狀態機、State Pattern!

延伸閱讀