📄 虛擬滾動
當頁面需要渲染 1000+ 筆資料時,虛擬滾動可以將 DOM 節點從 1000+ 降至 20-30 個,記憶體使用降低 80%。
📋 面試情境題
Q: 畫面上的 table 有不止一個,且如果各自有超過一百筆資料,同時又有頻繁更新 DOM 的事件,會用什麼方法去優化這頁的效能?
問題分析(Situation)
實際專案場景
在平台專案中,我們可能有頁面需要處理大量資料:
📊 某頁面歷史紀錄頁面
├─ 儲值記錄表格:1000+ 筆
├─ 提款記錄表格:800+ 筆
├─ 投注記錄表格:5000+ 筆
└─ 每筆記錄 8-10 個欄位(時間、金額、狀態等)
❌ 未優化的問題
├─ DOM 節點數:1000 筆 × 10 欄位 = 10,000+ 個節點
├─ 記憶體佔用:約 150-200 MB
├─ 首次渲染時間:3-5 秒(白屏)
├─ 滾動卡頓:FPS < 20
└─ WebSocket 更新時:整個表格重新渲染(非常卡)
問題嚴重性
// ❌ 傳統做法
<tr v-for="record in allRecords">  // 1000+ 筆全部渲染
  <td>{{ record.time }}</td>
  <td>{{ record.amount }}</td>
  // ... 8-10 個欄位
</tr>
// 結果:
// - 初始渲染:10,000+ 個 DOM 節點
// - 使用者實際可見:20-30 筆
// - 浪費:99% 的節點使用者根本看不到
解決方案(Action)
Virtual Scrolling(虛擬滾動)
先考慮虛擬滾動的優化問題,從這個角度出發,大概有兩個方向,一個是選擇官方背書的三方套件 vue-virtual-scroller,根據參數和需求,來決定可視範圍的 row。
// 只渲染可見區域的 row,例如:
// - 100 筆資料,只渲染可見的 20 筆
// - 大幅減少 DOM 節點數量
另一種則是選擇自己手刻,但考慮到實際開發的成本,以及涵括的情境,我應該會比較傾向採用官方背書的三方套件。
資料更新頻率控制
✅ 解法一:requestAnimationFrame(RAF) 概念:瀏覽器每秒最多重繪 60 次(60 FPS),更新再快人眼也看不到,所以我們配合螢幕刷新率更新
// ❌ 原本:收到資料就立刻更新(每秒可能 100 次)
socket.on('price', (newPrice) => {
  btcPrice.value = newPrice;
});
// ✅ 改良:收集資料,配合螢幕刷新率一次更新(每秒最多 60 次)
let latestPrice = null;
let isScheduled = false;
socket.on('price', (newPrice) => {
  latestPrice = newPrice; // 暫存最新價格
  if (!isScheduled) {
    isScheduled = true;
    requestAnimationFrame(() => {
      btcPrice.value = latestPrice; // 在瀏覽器準備重繪時才更新
      isScheduled = false;
    });
  }
});
✅ 解法二:throttle(節流) 概念:強制限制更新頻率,例如「每 100ms 最多更新 1 次」
// lodash 的 throttle(如果專案有用)
import { throttle } from 'lodash-es';
const updatePrice = throttle((newPrice) => {
  btcPrice.value = newPrice;
}, 100); // 每 100ms 最多執行 1 次
socket.on('price', updatePrice);
Vue3 特定優化
有一些 Vue3 的語法糖會提供優化效能,例如 v-memo,但我個人很少使用這個情境。
// 1. v-memo - 記憶化不常變動的列
<tr v-for="row in data"
  :key="row.id"
  v-memo="[row.price, row.volume]">  // 只在這些欄位變化時重新渲染
</tr>
// 2. 凍結靜態資料,避免響應式開銷
const staticData = Object.freeze(largeDataArray)
// 3. shallowRef 處理大陣列
const tableData = shallowRef([...])  // 只追蹤陣列本身,不追蹤內部物件
// 4. 使用 key 優化 diff 算法(讓唯一值的 id 來追蹤每個 item,讓 DOM 的更新可以局限在有變化的節點,節省效能)
<tr v-for="row in data" :key="row.id">  // 穩定的 key**
RAF:配合螢幕刷新(約 16ms),適合動畫、滾動 throttle:自訂間隔(如 100ms),適合搜尋、resize
DOM 渲染優化
// 使用 CSS transform 而非 top/left
.row-update {
  transform: translateY(0); /* 觸發 GPU 加速 */
  will-change: transform; /* 提示瀏覽器優化 */
}
// CSS containment 隔離渲染範圍
.table-container {
  contain: layout style paint;
}
優化成效(Result)
效能對比
| 指標 | 優化前 | 優化後 | 改善幅度 | 
|---|---|---|---|
| DOM 節點數 | 10,000+ | 20-30 | ↓ 99.7% | 
| 記憶體使用 | 150-200 MB | 30-40 MB | ↓ 80% | 
| 首次渲染 | 3-5 秒 | 0.3-0.5 秒 | ↑ 90% | 
| 滾動 FPS | < 20 | 55-60 | ↑ 200% | 
| 更新響應 | 500-800 ms | 16-33 ms | ↑ 95% | 
實際效果
✅ 虛擬滾動
├─ 只渲染可見的 20-30 筆
├─ 滾動時動態更新可見範圍
├─ 使用者無感知(體驗流暢)
└─ 記憶體穩定(不會隨資料量增長)
✅ RAF 資料更新
├─ WebSocket 每秒 100 次更新 → 最多 60 次渲染
├─ 配合螢幕刷新率(60 FPS)
└─ CPU 使用降低 60%
✅ Vue3 優化
├─ v-memo:避免不必要的重新渲染
├─ shallowRef:減少響應式開銷
└─ 穩定的 :key:優化 diff 算法
面試重點
常見延伸問題
Q: 如果不能用第三方 library 怎麼辦?
A: 自行實作虛擬滾動的核心邏輯:
// 核心概念
const itemHeight = 50; // 每行高度
const containerHeight = 600; // 容器高度
const visibleCount = Math.ceil(containerHeight / itemHeight); // 可見數量
// 計算當前應該顯示哪些項目
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;
關鍵點:
- 計算可見範圍(startIndex → endIndex)
 - 動態載入資料(slice)
 - 補償高度(padding top/bottom)
 - 監聽滾動事件(throttle 優化)
 
Q: WebSocket 斷線重連如何處理?
A: 實作指數退避重連策略:
let retryCount = 0;
const maxRetries = 5;
const baseDelay = 1000; // 1 秒
function reconnect() {
  if (retryCount >= maxRetries) {
    showError('無法連線,請重新整理頁面');
    return;
  }
  // 指數退避:1s → 2s → 4s → 8s → 16s
  const delay = baseDelay * Math.pow(2, retryCount);
  setTimeout(() => {
    retryCount++;
    connectWebSocket();
  }, delay);
}
// 重連成功後
socket.on('connect', () => {
  retryCount = 0; // 重置計數
  syncData(); // 同步資料
  showSuccess('連線已恢復');
});
Q: 如何測試效能  優化效果?
A: 使用多種工具組合:
// 1. Performance API 測量 FPS
let lastTime = performance.now();
let frames = 0;
function measureFPS() {
  frames++;
  const currentTime = performance.now();
  if (currentTime >= lastTime + 1000) {
    console.log(`FPS: ${frames}`);
    frames = 0;
    lastTime = currentTime;
  }
  requestAnimationFrame(measureFPS);
}
// 2. Memory Profiling(Chrome DevTools)
// - 渲染前拍快照
// - 渲染後拍快照
// - 比較記憶體差異
// 3. Lighthouse / Performance Tab
// - Long Task 時間
// - Total Blocking Time
// - Cumulative Layout Shift
// 4. 自動化測試(Playwright)
const { test } = require('@playwright/test');
test('virtual scroll performance', async ({ page }) => {
  await page.goto('/records');
  // 測量首次渲染時間
  const renderTime = await page.evaluate(() => {
    const start = performance.now();
    // 觸發渲染
    const end = performance.now();
    return end - start;
  });
  expect(renderTime).toBeLessThan(500); // < 500ms
});
Q: Virtual Scroll 有什麼缺點?
A: Trade-offs 需要注意:
❌ 缺點
├─ 無法使用瀏覽器原生搜尋(Ctrl+F)
├─ 無法使用「全選」功能(需要特殊處理)
├─ 實作複雜度較高
├─ 需要固定高度或提前計算高度
└─ 無障礙功能(Accessibility)需額外處理
✅ 適合場景
├─ 資料量 > 100 筆
├─ 每筆資料結構相似(高度固定)
├─ 需要高效能滾動
└─ 以查看為主(非編輯)
❌ 不適合場景
├─ 資料量 < 50 筆(過度設計)
├─ 高度不固定(實作困難)
├─ 需要大量互動(如多選、拖曳)
└─ 需要列印整個表  格
Q: 如何優化不等高的列表?
A: 使用動態高度虛擬滾動:
// 方案一:預估高度 + 實際測量
const estimatedHeight = 50; // 預估高度
const measuredHeights = {}; // 記錄實際高度
// 渲染後測量
onMounted(() => {
  const elements = document.querySelectorAll('.list-item');
  elements.forEach((el, index) => {
    measuredHeights[index] = el.offsetHeight;
  });
});
// 方案二:使用支援動態高度的套件
// vue-virtual-scroller 支援 dynamic-height
<DynamicScroller
  :items="items"
  :min-item-size="50"  // 最小高度
  :buffer="200"        // 緩衝區
/>
技術對比
Virtual Scroll vs 分頁
| 比較項目 | Virtual Scroll | 傳統分頁 | 
|---|---|---|
| 使用者體驗 | 連續滾動(更好) | 需要翻頁(中斷) | 
| 效能 | 始終只渲染可見範圍 | 每頁全部渲染 | 
| 實作難度 | 較複雜 | 簡單 | 
| SEO 友好 | 較差 | 較好 | 
| 無障礙 | 需特殊處理 | 原生支援 | 
建議:
- 後台系統、Dashboard → Virtual Scroll
 - 公開網站、部落格 → 傳統分頁
 - 混合方案:Virtual Scroll + 「載入更多」按鈕