5 萬(wàn)條數(shù)據(jù)不卡!虛擬列表終極方案來(lái)了!
說(shuō)到虛擬列表應(yīng)該沒(méi)有同學(xué)不知道吧,這是目前很多同學(xué)面試的時(shí)候經(jīng)常會(huì)作為項(xiàng)目難點(diǎn)來(lái)描述的內(nèi)容。
大多數(shù)同學(xué)針對(duì)虛擬列表時(shí),都會(huì)說(shuō):服務(wù)端一口氣給我們返回 幾萬(wàn)條數(shù)據(jù),我們通過(guò)虛擬列表的方式進(jìn)行渲染。
然后,面試官通常都會(huì)通過(guò)一句話堵死你:為啥服務(wù)端一定要返回幾萬(wàn)條數(shù)據(jù)呢?
額。。。尷尬啊。。。
所以,咱們今天這篇文章,就主要解決 面試聊到虛擬列表 的兩個(gè)核心問(wèn)題:
- 虛擬列表的 真實(shí)體現(xiàn)場(chǎng)景 是什么?
- 虛擬列表的 終極解決方案 是什么?
真實(shí)體現(xiàn)場(chǎng)景
面試官問(wèn)的沒(méi)錯(cuò):正常情況下,服務(wù)端沒(méi)必要一次性返回 5 萬(wàn)條數(shù)據(jù)。
那為什么我們還需要虛擬列表呢?
其實(shí),在實(shí)際業(yè)務(wù)中,虛擬列表的需求非常普遍,遠(yuǎn)遠(yuǎn)不止“服務(wù)端一次性返回幾萬(wàn)條”。
下面給大家拆幾個(gè)真實(shí)落地的場(chǎng)景:
1. 后臺(tái)系統(tǒng):訂單、日志管理
做后臺(tái)開(kāi)發(fā)的同學(xué)一定熟悉:訂單列表、操作日志、用戶流水,動(dòng)輒幾十萬(wàn)條。
雖然前端一般會(huì)分頁(yè),但有時(shí)候業(yè)務(wù)場(chǎng)景要求:
- 支持無(wú)限滾動(dòng)加載(例如用戶下拉快速翻查訂單)。
- 支持快速定位(跳轉(zhuǎn)到第 N 頁(yè)、第 N 條)。
這種場(chǎng)景下,即便分批請(qǐng)求,只要用戶不斷的上啦加載更多的數(shù)據(jù),前端依然要承載數(shù)萬(wàn)條數(shù)據(jù)的渲染。
2. 電商、內(nèi)容流:無(wú)限加載
在 App 或 H5 頁(yè)面,商品流、視頻流、評(píng)論區(qū)等業(yè)務(wù)場(chǎng)景普遍采用“無(wú)限滾動(dòng)”的交互。
既:用戶可以一直往下滑,直到加載幾千、甚至上萬(wàn)條數(shù)據(jù)。
那么在這種情況下,如果你直接用 v-for 渲染所有數(shù)據(jù),內(nèi)存和 DOM 數(shù)量很快就爆掉。
所以,此時(shí)就必須要使用 虛擬列表 了
3. 數(shù)據(jù)大屏:實(shí)時(shí)推送
很多公司都會(huì)做數(shù)據(jù)可視化大屏,這種項(xiàng)目又一個(gè)特點(diǎn),那就是: 實(shí)時(shí)展示告警流、消息流、交易流水,并且數(shù)據(jù)量是實(shí)時(shí)刷新的、不斷累積的
同時(shí),需求通常要求“全量展示”,不能只保留最新幾條。
這種情況下,傳統(tǒng)渲染很快就頂不住了,只有 虛擬列表才能保證大屏不卡頓。
4. 聊天、IM:動(dòng)態(tài)高度 & 無(wú)限消息
聊天窗口也是典型場(chǎng)景之一。
通常情況下,聊天記錄會(huì)隨著用戶滾動(dòng)不斷加載歷史消息,每條消息高度還可能不一致(文本、圖片、語(yǔ)音混合)。
那么在這樣的條件下,我們又必須要保證加載上萬(wàn)條消息依然流暢,還要支持“滾動(dòng)到底部”邏輯。
這類場(chǎng)景更復(fù)雜,需要虛擬列表的動(dòng)態(tài)高度方案。
終極解決方案
上面聊了真實(shí)場(chǎng)景,那么問(wèn)題來(lái)了:虛擬列表到底是怎么解決幾萬(wàn)條數(shù)據(jù)不卡頓的?
一句話總結(jié):
虛擬列表的核心就是:只渲染用戶能看到的部分,其他內(nèi)容用“假的”代替。
三大核心要素
要讓虛擬列表真正跑起來(lái),必須搞定這三個(gè)關(guān)鍵點(diǎn):
- 可視區(qū)渲染:頁(yè)面上顯示多少內(nèi)容,就只渲染這些內(nèi)容。比如屏幕高度能容納 10 條數(shù)據(jù),那就只創(chuàng)建 10 條 DOM,而不是 50000 條。
- 緩沖區(qū):滾動(dòng)時(shí)如果只渲染剛好可見(jiàn)的內(nèi)容,可能會(huì)出現(xiàn)“滾動(dòng)過(guò)快導(dǎo)致白屏”。解決辦法是:在上下區(qū)域額外渲染一些數(shù)據(jù)(比如上下各多渲染 5 條),即所謂“緩沖區(qū)”。
- 占位高度(位置計(jì)算):用戶看到的只是局部,但滾動(dòng)條必須是全量的。
通常做法:用一個(gè)虛擬的容器高度(總數(shù)據(jù)條數(shù) × 每條高度)來(lái)?yè)纹饾L動(dòng)條。然后通過(guò) transform: translateY(...) 或 margin-top 來(lái)調(diào)整渲染元素的位置,看起來(lái)就像“在滾動(dòng)”。
工作流程拆解
- 用戶滾動(dòng)時(shí),計(jì)算當(dāng)前的 scrollTop。
- 根據(jù)
scrollTop推算出 起始索引(startIndex) 和 結(jié)束索引(endIndex)。 - 截取
listData[startIndex ~ endIndex]作為 渲染區(qū)數(shù)據(jù)。 - 用一個(gè)大容器元素模擬總高度,再通過(guò)
translateY(offsetY)把可見(jiàn)內(nèi)容放到正確的位置。
這樣,不管列表有 5 千條還是 5 萬(wàn)條,瀏覽器永遠(yuǎn)只需要渲染幾十個(gè) DOM 節(jié)點(diǎn),性能從根本上被優(yōu)化。
實(shí)例代碼
最后咱們就以 Vue 為例,來(lái)看下如何實(shí)現(xiàn)這個(gè)虛擬列表方案
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"
>
<!-- 占位高度:撐滾動(dòng)條 -->
<div :style="{ height: totalHeight + 'px' }" aria-hidden="true"></div>
<!-- 可視區(qū):絕對(duì)定位 + 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 {
/* 父容器必須相對(duì)定位 + 可滾動(dòng) */
position: relative;
overflow-y: auto;
border: 1px solid #e5e7eb;
background: #fff;
}
/* 關(guān)鍵:絕對(duì)定位到頂部 + 蓋住占位層 */
.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('可見(jiàn)區(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>最終渲染效果
圖片




























