owned this note
owned this note
Published
Linked with GitHub
# 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)
}
}
```
---
*如有錯誤或建議,歡迎留言指正!*