前端虛擬長列表

當一次性渲染十萬條 DOM 節點時,瀏覽器會瞬間陷入「卡頓—白屏—崩潰」三連擊。虛擬長列表(Virtual Scroller)把「按需渲染」做到極致:只繪製可見區域並加少量緩衝,讓巨量數據在低端設備也能保持 60 FPS。

一、問題本質:渲染成本與滾動成本的矛盾

渲染成本等於節點數量乘以單個節點複雜度,滾動成本等於佈局重排乘以樣式重繪。瀏覽器單幀預算約 16 ms,若一次迴流就耗時 30 ms,動畫必然掉幀。虛擬化的核心思路是把 O(N) 的渲染複雜度降為 O(屏幕可顯示的最大條數)。

二、設計總覽:三段式流水線

度量層:計算總高度,撐開滾動條,欺騙瀏覽器這裏真的有十萬條。
切片層:監聽 scroll,根據滾動距離反推出首條索引與尾條索引。
渲染層:用絕對定位把切片渲染到正確位置,維持視覺連續性。

三、緩衝區與索引計算

獲取當前滾動距離 scrollTop 與容器可視高度 clientHeight,先計算首條索引 startIndex 與尾條索引 endIndex,再前後各擴展 prev/next 條作為緩衝,避免快速滾動時出現空白閃爍。startPos 為首條切片距離容器頂部的絕對偏移量,用於後續 translateY。

const startIndex = Math.floor(scrollTop / itemSize) - prev
const endIndex   = Math.ceil((scrollTop + clientHeight) / itemSize) + next
const startPos   = startIndex * itemSize

隨後用 slice 取出數據區間並映射成渲染池 pool,每個元素攜帶原始 item 與 position。

四、絕對定位與 transform 的選擇

top 會觸發重排,transform 只觸發合成層重繪。合成層由 GPU 處理,主線程壓力降低 80% 以上。搭配 will-change: transform 提前提升圖層,低端機也能穩住 60 FPS。

五、性能陷阱與修復要點

動態行高場景下,度量層計算失準,可用預掃描或 ResizeObserver 緩存每行真實高度。快速滾動出現白屏閃爍時,可加大 prev/next 緩衝量,並用 requestAnimationFrame 節流 scroll 事件。組件卸載時務必移除 scroll 監聽器,防止內存泄漏。虛擬行內部若使用 v-model,每次輸入都會觸發全表更新,可改用 .lazy 或手動提交,避免動畫掉幀。

六、代碼實例

<template>
  <div class="scroller" @scroll="update">
    <div class="spacer" :style="{ height: totalHeight + 'px' }"></div>
    <div
      class="row"
      v-for="row in pool"
      :key="row.key"
      :style="{ transform: `translateY(${row.y}px)` }"
    >
      {{ row.item.text }}
    </div>
  </div>
</template>

<script>
export default {
  props: {
    items: Array,
    itemHeight: { type: Number, default: 50 }
  },
  data: () => ({ pool: [] }),
  computed: {
    totalHeight() {
      return this.items.length * this.itemHeight;
    }
  },
  methods: {
    update() {
      const st = this.$el.scrollTop;
      const ch = this.$el.clientHeight;
      const start = Math.floor(st / this.itemHeight);
      const end   = Math.ceil((st + ch) / this.itemHeight);
      const y = start * this.itemHeight;
      this.pool = this.items.slice(start, end).map((item, i) => ({
        key: item.id,
        item,
        y: y + i * this.itemHeight
      }));
    }
  },
  mounted() {
    this.update();
  }
};
</script>

<style>
.scroller { height: 400px; overflow: auto; position: relative; }
.spacer   { position: absolute; top: 0; left: 0; right: 0; }
.row      { position: absolute; left: 0; right: 0; height: 50px; }
</style>