5 萬條數(shù)據(jù)不卡!虛擬列表終極方案來了!
說到虛擬列表應該沒有同學不知道吧,這是目前很多同學面試的時候經(jīng)常會作為項目難點來描述的內(nèi)容。
大多數(shù)同學針對虛擬列表時,都會說:服務端一口氣給我們返回 幾萬條數(shù)據(jù),我們通過虛擬列表的方式進行渲染。
然后,面試官通常都會通過一句話堵死你:為啥服務端一定要返回幾萬條數(shù)據(jù)呢?
額。。。尷尬啊。。。
所以,咱們今天這篇文章,就主要解決 面試聊到虛擬列表 的兩個核心問題:
- 虛擬列表的 真實體現(xiàn)場景 是什么?
- 虛擬列表的 終極解決方案 是什么?
真實體現(xiàn)場景
面試官問的沒錯:正常情況下,服務端沒必要一次性返回 5 萬條數(shù)據(jù)。
那為什么我們還需要虛擬列表呢?
其實,在實際業(yè)務中,虛擬列表的需求非常普遍,遠遠不止“服務端一次性返回幾萬條”。
下面給大家拆幾個真實落地的場景:
1. 后臺系統(tǒng):訂單、日志管理
做后臺開發(fā)的同學一定熟悉:訂單列表、操作日志、用戶流水,動輒幾十萬條。
雖然前端一般會分頁,但有時候業(yè)務場景要求:
- 支持無限滾動加載(例如用戶下拉快速翻查訂單)。
- 支持快速定位(跳轉(zhuǎn)到第 N 頁、第 N 條)。
這種場景下,即便分批請求,只要用戶不斷的上啦加載更多的數(shù)據(jù),前端依然要承載數(shù)萬條數(shù)據(jù)的渲染。
2. 電商、內(nèi)容流:無限加載
在 App 或 H5 頁面,商品流、視頻流、評論區(qū)等業(yè)務場景普遍采用“無限滾動”的交互。
既:用戶可以一直往下滑,直到加載幾千、甚至上萬條數(shù)據(jù)。
那么在這種情況下,如果你直接用 v-for 渲染所有數(shù)據(jù),內(nèi)存和 DOM 數(shù)量很快就爆掉。
所以,此時就必須要使用 虛擬列表 了
3. 數(shù)據(jù)大屏:實時推送
很多公司都會做數(shù)據(jù)可視化大屏,這種項目又一個特點,那就是: 實時展示告警流、消息流、交易流水,并且數(shù)據(jù)量是實時刷新的、不斷累積的
同時,需求通常要求“全量展示”,不能只保留最新幾條。
這種情況下,傳統(tǒng)渲染很快就頂不住了,只有 虛擬列表才能保證大屏不卡頓。
4. 聊天、IM:動態(tài)高度 & 無限消息
聊天窗口也是典型場景之一。
通常情況下,聊天記錄會隨著用戶滾動不斷加載歷史消息,每條消息高度還可能不一致(文本、圖片、語音混合)。
那么在這樣的條件下,我們又必須要保證加載上萬條消息依然流暢,還要支持“滾動到底部”邏輯。
這類場景更復雜,需要虛擬列表的動態(tài)高度方案。
終極解決方案
上面聊了真實場景,那么問題來了:虛擬列表到底是怎么解決幾萬條數(shù)據(jù)不卡頓的?
一句話總結(jié):
虛擬列表的核心就是:只渲染用戶能看到的部分,其他內(nèi)容用“假的”代替。
三大核心要素
要讓虛擬列表真正跑起來,必須搞定這三個關(guān)鍵點:
- 可視區(qū)渲染:頁面上顯示多少內(nèi)容,就只渲染這些內(nèi)容。比如屏幕高度能容納 10 條數(shù)據(jù),那就只創(chuàng)建 10 條 DOM,而不是 50000 條。
- 緩沖區(qū):滾動時如果只渲染剛好可見的內(nèi)容,可能會出現(xiàn)“滾動過快導致白屏”。解決辦法是:在上下區(qū)域額外渲染一些數(shù)據(jù)(比如上下各多渲染 5 條),即所謂“緩沖區(qū)”。
- 占位高度(位置計算):用戶看到的只是局部,但滾動條必須是全量的。
通常做法:用一個虛擬的容器高度(總數(shù)據(jù)條數(shù) × 每條高度)來撐起滾動條。然后通過 transform: translateY(...) 或 margin-top 來調(diào)整渲染元素的位置,看起來就像“在滾動”。
工作流程拆解
- 用戶滾動時,計算當前的 scrollTop。
- 根據(jù)
scrollTop推算出 起始索引(startIndex) 和 結(jié)束索引(endIndex)。 - 截取
listData[startIndex ~ endIndex]作為 渲染區(qū)數(shù)據(jù)。 - 用一個大容器元素模擬總高度,再通過
translateY(offsetY)把可見內(nèi)容放到正確的位置。
這樣,不管列表有 5 千條還是 5 萬條,瀏覽器永遠只需要渲染幾十個 DOM 節(jié)點,性能從根本上被優(yōu)化。
實例代碼
最后咱們就以 Vue 為例,來看下如何實現(xiàn)這個虛擬列表方案
VirtualList.vue
<script setup>
import { ref, computed, onMounted, nextTick, watch, defineExpose } from 'vue'
const props = defineProps({
items: { type: Array, required: true }, // 全量數(shù)據(jù)
height: { type: Number, required: true }, // 容器高度
itemHeight: { type: Number, required: true }, // 每行固定高度
buffer: { type: Number, default: 6 }, // 緩沖條數(shù)
keyField: { type: String, default: 'id' } // 唯一 key
})
const emit = defineEmits(['rangeChange', 'reachEnd'])
const containerRef = ref(null)
const scrollTop = ref(0)
const visibleCount = computed(() => Math.ceil(props.height / props.itemHeight))
const totalHeight = computed(() => props.items.length * props.itemHeight)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.buffer)
)
const endIndex = computed(() =>
Math.min(
props.items.length,
startIndex.value + visibleCount.value + props.buffer * 2
)
)
const offsetY = computed(() => startIndex.value * props.itemHeight)
const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
)
function onScroll() {
const el = containerRef.value
if (!el) return
scrollTop.value = el.scrollTop
emit('rangeChange', { start: startIndex.value, end: endIndex.value })
if (endIndex.value >= props.items.length - props.buffer * 2) emit('reachEnd')
}
function scrollToIndex(index, align = 'start') {
const el = containerRef.value
if (!el) return
const clamped = Math.max(0, Math.min(index, props.items.length - 1))
let top = clamped * props.itemHeight
if (align === 'center') top -= (props.height - props.itemHeight) / 2
else if (align === 'end') top -= props.height - props.itemHeight
el.scrollTop = Math.max(0, top)
onScroll()
}
function reset() {
const el = containerRef.value
if (!el) return
el.scrollTop = 0
onScroll()
}
onMounted(() => nextTick(onScroll))
watch(
() => props.items.length,
async () => {
await nextTick()
onScroll()
}
)
defineExpose({ scrollToIndex, reset })
</script>
<template>
<div
ref="containerRef"
class="vl-container"
:style="{ height: height + 'px' }"
@scroll="onScroll"
>
<!-- 占位高度:撐滾動條 -->
<div :style="{ height: totalHeight + 'px' }" aria-hidden="true"></div>
<!-- 可視區(qū):絕對定位 + translateY -->
<div class="vl-list" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="(row, i) in visibleItems"
:key="row?.[keyField] ?? startIndex + i"
class="vl-item"
:style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }"
>
<slot name="row" :row="row" :index="startIndex + i"></slot>
</div>
</div>
</div>
</template>
<style>
.vl-container {
/* 父容器必須相對定位 + 可滾動 */
position: relative;
overflow-y: auto;
border: 1px solid #e5e7eb;
background: #fff;
}
/* 關(guān)鍵:絕對定位到頂部 + 蓋住占位層 */
.vl-list {
position: absolute;
top: 0;
left: 0;
right: 0;
width: 100%;
z-index: 1;
will-change: transform;
}
.vl-item {
padding: 0 12px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
</style>App.vue
<script setup>
import { ref } from 'vue'
import VirtualList from './VirtualList.vue'
const items = ref(
Array.from({ length: 50000 }, (_, i) => ({
id: i,
name: `訂單 #${i}`,
amount: (Math.random() * 1000).toFixed(2)
}))
)
function onRangeChange(r) {
console.log('可見區(qū)范圍:', r)
}
function onReachEnd() {
console.log('觸底了,加載更多數(shù)據(jù)...')
setTimeout(() => {
const base = items.value.length
items.value.push(
...Array.from({ length: 1000 }, (_, i) => ({
id: base + i,
name: `訂單 #${base + i}`,
amount: (Math.random() * 1000).toFixed(2)
}))
)
}, 500)
}
</script>
<template>
<VirtualList
:items="items"
:height="560"
:item-height="44"
:buffer="8"
key-field="id"
@rangeChange="onRangeChange"
@reachEnd="onReachEnd"
>
<template #row="{ row, index }">
<span style="margin-right: 8px">{{ index }}</span>
{{ row.name }} -- ¥{{ row.amount }}
</template>
</VirtualList>
</template>最終渲染效果
圖片




























