[Lv3] 大量資料優化策略:方案選擇與實作
當畫面需要顯示上萬筆資料時,如何在效能、使用者體驗和開發成本間取得平衡?
📋 面試情境題
Q: 當畫面上有上萬筆資料時,該如何進行優化處理?
這是一個開放性問題,面試官期待聽到的不只是單一解決方案,而是:
- 需求評估:真的需要一次顯示這麼多資料嗎?
- 方案選擇:有哪些方案?各自的優缺點?
- 全面思考:前端 + 後端 + UX 的綜合考量
- 實際經驗:選擇的理由和實施效果
第一步:需求評估
在選擇技術方案前,先問自己這些問題:
核心問題
❓ 使用者真的需要看到所有資料嗎?
→ 大部分情況下,使用者只關心前 50-100 筆
→ 可以透過篩選、搜尋、排序來縮小範圍
❓ 資料是否需要實時更新?
→ WebSocket 即時更新 vs 定時輪詢 vs 僅初次載入
❓ 使用者的操作模式是什麼?
→ 瀏覽為主 → 虛擬滾動
→ 查找特定資料 → 搜尋 + 分頁
→ 逐筆檢視 → 無限滾動
❓ 資料結構是否固定?
→ 高度固定 → 虛擬滾動容易實作
→ 高度不固定 → 需要動態高度計算
❓ 是否需要全部選取、列印、匯出?
→ 需要 → 虛擬滾動會有限制
→ 不需要 → 虛擬滾動最佳選擇
實際案例分析
// 案例 1:歷史交易記錄(10,000+ 筆)
使用者行為:查看最近的交易、偶爾搜尋特定日期
最佳方案:後端分頁 + 搜尋
// 案例 2:即時遊戲列表(3,000+ 款)
使用者行為:瀏覽、分類篩選、流暢滾動
最佳方案:虛擬滾動 + 前端篩選
// 案例 3:社交動態(無限增長)
使用者行為:持續往下滑、不需要跳頁
最佳方案:無限滾動 + 分批載入
// 案例 4:數據報表(複雜表格)
使用者行為:查看、排序、匯出
最佳方案:後端分頁 + 匯出 API
💡 優化方案總覽
方案對比表
| 方案 | 適用場景 | 優點 | 缺點 | 實作難度 | 效能 |
|---|---|---|---|---|---|
| 後端分頁 | 大部分場景 | 簡單可靠、SEO 友好 | 需要翻頁、體驗中斷 | 1/5 簡單 | 3/5 中等 |
| 虛擬滾動 | 大量固定高度資料 | 極致效能、流暢滾動 | 實作複雜、無法原生搜尋 | 4/5 複雜 | 5/5 極佳 |
| 無限滾動 | 社交媒體、新聞流 | 連續體驗、實作簡單 | 記憶體累積、無法跳頁 | 2/5 簡單 | 3/5 中等 |
| 數據分批 | 初次載入優化 | 漸進式載入、配合骨架屏 | 需要後端配合 | 2/5 簡單 | 3/5 中等 |
| Web Worker | 大量計算、排序、過濾 | 不阻塞主線程 | 通訊開銷、除錯困難 | 3/5 中等 | 4/5 良好 |
| 混合方案 | 複雜需求 | 結合多種方案優點 | 複雜度高 | 4/5 複雜 | 4/5 良好 |
📚 方案詳解
1. 後端分頁(Pagination)★ 首選方案
推薦指數:5/5(強烈推薦)
最常見、最可靠的方案,適合 80% 的場景
實作方式
// 前端請求
async function fetchData(page = 1, pageSize = 20) {
const response = await fetch(`/api/data?page=${page}&pageSize=${pageSize}`);
return response.json();
}
// 後端 API(以 Node.js + MongoDB 為例)
app.get('/api/data', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const pageSize = parseInt(req.query.pageSize) || 20;
const skip = (page - 1) * pageSize;
const data = await Collection.find().skip(skip).limit(pageSize).lean(); // 只返回純物件,不含 Mongoose 方法
const total = await Collection.countDocuments();
res.json({
data,
pagination: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
});
});
優化技巧
// 1. 游標分頁(Cursor-based Pagination)
// 適合實時更新的資料,避免重複或遺漏
const data = await Collection.find({ _id: { $gt: cursor } })
.limit(20)
.sort({ _id: 1 });
// 2. 快取熱門頁面
const cacheKey = `data:page:${page}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 3. 只返回必要欄位
const data = await Collection.find()
.select('id name price status') // 只選取需要的欄位
.skip(skip)
.limit(pageSize);
適用場景
✅ 適合
├─ 管理後台(訂單列表、使用者列表)
├─ 資料查詢系統(歷史記錄)
├─ 公開網站(部落格、新聞)
└─ 需要 SEO 的頁面
❌ 不適合
├─ 需要流暢滾動體驗
├─ 實時更新的列表(分頁可能跳動)
└─ 社交媒體類應用
2. 虛擬滾動(Virtual Scrolling)★ 極致效能
推薦指數:4/5(推薦)
效能最佳,適合大量固定高度資料
虛擬滾動是一種只渲染可見區域的技術,將 DOM 節點從 10,000+ 降至 20-30 個,記憶體使用降低 80%。
核心概念
// 只渲染可見範圍的資料
const itemHeight = 50; // 每項高度
const containerHeight = 600; // 容器高度
const visibleCount = Math.ceil(containerHeight / itemHeight); // 可見數量 = 12
// 計算當前應該顯示哪些項目
const scrollTop = container.scrollTop;
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
// 只渲染這個範圍
const visibleItems = allItems.slice(startIndex, endIndex);
// 用 padding 補償高度(讓滾動條正確)
const paddingTop = startIndex * itemHeight;
const paddingBottom = (allItems.length - endIndex) * itemHeight;
實作方式
<!-- 使用 vue-virtual-scroller -->
<template>
<RecycleScroller
class="scroller"
:items="items"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="item">{{ item.name }}</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const items = ref(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
}))
);
</script>
效能對比
| 指標 | 傳統渲染 | 虛擬滾動 | 改善幅度 |
|---|---|---|---|
| DOM 節點數 | 10,000+ | 20-30 | ↓ 99.7% |
| 記憶體使用 | 150 MB | 30 MB | ↓ 80% |
| 首次渲染 | 3-5 秒 | 0.3 秒 | ↑ 90% |
| 滾動 FPS | < 20 | 55-60 | ↑ 200% |
詳細說明
👉 深入瞭解:虛擬滾動完整實作 →
3. 無限滾動(Infinite Scroll)★ 連續體驗
推薦指數:3/5(可考慮)
適合社交媒體、新聞流等連續瀏覽的場景
實作方式
<template>
<div ref="scrollContainer" @scroll="handleScroll">
<div v-for="item in displayedItems" :key="item.id">
{{ item.name }}
</div>
<div v-if="loading" class="loading">載入中...</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const displayedItems = ref([]);
const loading = ref(false);
const currentPage = ref(1);
const hasMore = ref(true);
// 初次載入
onMounted(() => {
loadMore();
});
// 載入更多資料
async function loadMore() {
if (loading.value || !hasMore.value) return;
loading.value = true;
const { data, hasNext } = await fetchData(currentPage.value);
displayedItems.value.push(...data);
hasMore.value = hasNext;
currentPage.value++;
loading.value = false;
}
// 滾動監聽
function handleScroll(e) {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 距離底部 100px 時觸發載入
if (scrollTop + clientHeight >= scrollHeight - 100) {
loadMore();
}
}
</script>
優化技巧
// 1. 使用 IntersectionObserver(效能更好)
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ rootMargin: '100px' } // 提前 100px 觸發
);
// 觀察最後一個元素
const lastItem = document.querySelector('.item:last-child');
observer.observe(lastItem);
// 2. 節流控制(避免快速滾動時多次觸發)
import { throttle } from 'lodash';
const handleScroll = throttle(checkAndLoadMore, 200);
// 3. 虛擬化卸載(避免記憶體累積)
// 當資料超過 500 筆時,卸載最前面的資料
if (displayedItems.value.length > 500) {
displayedItems.value = displayedItems.value.slice(-500);
}
適用場景
✅ 適合
├─ 社交媒體動態(Facebook、Twitter)
├─ 新聞列表、文章列表
├─ 商品瀑布流
└─ 連續瀏覽為主的場景
❌ 不適合
├─ 需要跳頁查看特定資料
├─ 資料總量需要顯示(如「共 10,000 筆」)
└─ 需要回到頂部的場景(滑太久回不去)