# Nuxt 3 無限滾動載入 ## 功能說明 實作無限滾動(Infinite Scroll)與惰性載入(Lazy Loading)功能,當使用者滾動至頁面底部時自動載入更多資料。 > **注意**: 此實作需要後端 API 支援分頁參數(`limit` 和 `page`)。 --- ## 完整程式碼 ### Template 結構 ```vue <template> <div> <section class="showListBox"> <template v-if="showData.length === 0"> 目前尚未有資料 </template> <template v-else> <div v-for="(item, index) in showData" :key="index" class="imgBox"> <img :src="item.image_url" :alt="imgName(item.image)"> </div> </template> </section> <div ref="lastItemRef"></div> </div> </template> ``` ### Script 邏輯 ```vue <script setup> // ==================== 狀態定義 ==================== const showData = ref([]) // 頁面顯示的資料 const currentPage = ref(1) // 當前頁碼 const isNext = ref(false) // 是否還有下一頁 const lastItemRef = ref(null) // 最後一個元素的參考 const limit = ref(12) // 每次請求的筆數 // ==================== API 參數設定 ==================== const query = ref(null) const setQuery = () => { query.value = { limit: limit.value, page: currentPage.value, sort: "desc", sort_col: "date" } // 可依需求加入搜尋條件 // if (searchKeywords.value) { // query.value.search = searchKeywords.value // } } // ==================== 資料獲取 ==================== const getData = async () => { try { setQuery() const data = await $fetch("Your Api Url", { method: "GET", params: query.value }) // 將新資料追加至現有資料 showData.value = [...showData.value, ...data.data] // 判斷是否還有下一頁 isNext.value = data.data.length >= limit.value } catch (error) { console.error("API 請求失敗:", error) clearError() } } // 初始載入 getData() // ==================== 滾動事件處理 ==================== const handleScroll = () => { // 無資料時不處理 if (showData.value.length === 0) return // 確認最後一個元素存在 if (!lastItemRef.value) return const scrollY = window.scrollY // 當前捲軸位置 const windowHeight = window.innerHeight // 視窗高度 const lastItemRect = lastItemRef.value.getBoundingClientRect() // 判斷是否滾動至底部且有下一頁 if (scrollY + windowHeight >= lastItemRect.top && isNext.value) { loadMoreData() } } // ==================== 載入更多資料 ==================== const loadMoreData = async () => { isNext.value = false // 防止重複請求 currentPage.value += 1 // 頁碼+1 await getData() } // ==================== 生命週期 ==================== onMounted(() => { window.addEventListener('scroll', handleScroll) }) onUnmounted(() => { window.removeEventListener('scroll', handleScroll) }) </script> ``` --- ## 核心原理說明 ### 1. getBoundingClientRect() 方法 使用 `getBoundingClientRect()` 取得元素相對於視窗的位置資訊。 📚 [MDN 官方文件](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) ### 2. 滾動判斷邏輯 ```javascript scrollY + windowHeight >= lastItemRect.top ``` - `scrollY`: 捲軸距離頂部的距離 - `windowHeight`: 瀏覽器視窗高度 - `lastItemRect.top`: 最後元素距離視窗頂部的距離 當兩者相加大於等於最後元素位置時,表示已滾動至底部。 --- ## API 參數說明 | 參數 | 類型 | 說明 | 範例 | |------|------|------|------| | `limit` | Number | 每次請求的資料筆數 | 12 | | `page` | Number | 當前頁碼 | 1, 2, 3... | | `sort` | String | 排序方式 | "desc", "asc" | | `sort_col` | String | 排序欄位 | "date", "id" | --- ## 注意事項 - ✅ 需要後端 API 支援分頁功能 - ✅ 記得在 `onUnmounted` 時移除事件監聽器 - ✅ 建議加入 loading 狀態提升使用者體驗 - ✅ 可搭配骨架屏(Skeleton)增強視覺效果 --- ## 常見問題排除 ### ❌ 錯誤: `getboundingclientrect is not a function` #### 原因 `ref` 綁定的元素可能尚未正確掛載或取得。 #### 解決方案 **方法一: 使用 ID 選擇器**(不建議) ```vue <!-- Template --> <div id="lastItemRef"></div> ``` ```javascript // Script const handleScroll = () => { try { lastItemRef.value = document.querySelector("#lastItemRef") } catch (error) { lastItemRef.value = null } if (lastItemRef.value) { const lastItemRect = lastItemRef.value.getBoundingClientRect() // ...後續邏輯 } } ``` **方法二: 確保 ref 正確綁定**(建議) ```javascript // 確認元素已掛載 if (lastItemRef.value && typeof lastItemRef.value.getBoundingClientRect === 'function') { const lastItemRect = lastItemRef.value.getBoundingClientRect() // ...後續邏輯 } ``` --- ## 待執行優化方向 > 以下為尚未實踐的優化方向,僅供參考 ### 1. 改用游標分頁(Cursor-based Pagination)【重要】 **⚠️ 當前 page + limit 方式的問題:** ```javascript // 情境:你正在看第2頁,後台新增了一筆資料 第1頁: 資料 1-12 第2頁: 資料 13-24 ← 你正在載入這頁 // 後台新增資料後 第1頁: 新資料 + 資料 1-11 ← 資料被往後擠 第2頁: 資料 12-23 ← 資料12重複出現! ``` **✅ 游標分頁的解決方案:** 核心概念:使用「單號」(cursor)標記位置,而非頁碼 ```javascript // 第一次請求 const query = { limit: 12 // 不需要 page } // 後端回傳 { data: [...], next_cursor: "eyJpZCI6MTIzfQ==" // 下次請求的起點 } // 第二次請求 const query = { limit: 12, cursor: "eyJpZCI6MTIzfQ==" // 從上次結束的地方繼續 } ``` **實作範例:** ```javascript const showData = ref([]) const nextCursor = ref(null) // 取代 currentPage const isNext = ref(false) const limit = ref(12) const setQuery = () => { query.value = { limit: limit.value, sort: "desc", sort_col: "date" } // 第二次請求起才帶 cursor if (nextCursor.value) { query.value.cursor = nextCursor.value } } const getData = async () => { try { setQuery() const data = await $fetch("Your Api Url", { method: "GET", params: query.value }) showData.value = [...showData.value, ...data.data] // 儲存下次請求的 cursor nextCursor.value = data.next_cursor isNext.value = !!data.next_cursor // 有 cursor 就代表還有下一頁 } catch (error) { console.error("API 請求失敗:", error) } } const loadMoreData = async () => { isNext.value = false await getData() } ``` **優點:** - ✅ 不會因為資料新增/刪除而重複或遺漏 - ✅ 效能更好(後端不需要 OFFSET 計算) - ✅ 適合即時更新的應用 - ⚠️ 需要後端配合調整 API ### 2. 使用 Intersection Observer 更現代且效能更好的方式,不需要監聽 scroll 事件: ```javascript const observer = ref(null) const setupObserver = () => { observer.value = new IntersectionObserver( (entries) => { // 當最後一個元素進入視窗時觸發 if (entries[0].isIntersecting && isNext.value) { loadMoreData() } }, { threshold: 0.1 } // 元素 10% 可見時觸發 ) if (lastItemRef.value) { observer.value.observe(lastItemRef.value) } } onMounted(() => { setupObserver() }) onUnmounted(() => { if (observer.value) { observer.value.disconnect() } }) ``` **優點:** - ✅ 瀏覽器原生支援,效能最佳 - ✅ 不需要計算位置,自動偵測 - ✅ 無延遲,觸發時機準確 - ✅ 不會有 API 請求混亂的問題 ### 3. 加入載入狀態 提升使用者體驗: ```javascript const isLoading = ref(false) const loadMoreData = async () => { if (isLoading.value) return isLoading.value = true // ...載入邏輯 isLoading.value = false } ``` ### 4. 錯誤處理 更完善的錯誤提示: ```javascript const error = ref(null) const getData = async () => { try { error.value = null // ...請求邏輯 } catch (err) { error.value = '載入失敗,請稍後再試' console.error(err) } } ``` --- *如有錯誤或建議,歡迎留言指正!*