问题
正常的列表渲染,有多少条消息就创建多少个 DOM 节点。对话轮次少的时候没问题,但客服场景里用户可能聊几十上百轮,每条消息里还有代码块、引用卡片这些复杂结构,DOM 节点数量会非常多。
具体会出现三个问题:
问题一:滚动卡顿
100 条消息,每条消息平均 10 个 DOM 节点,就是 1000 个节点在页面上。用户滚动的时候浏览器要计算所有节点的位置,帧率直接掉下来,滑起来一顿一顿的。
问题二:首次进入历史对话慢
用户重新打开一个聊了 80 轮的对话,前端一次性把 80 条消息全部渲染出来,页面要卡好几秒才能响应,用户以为页面崩了。
问题三:内存持续增长
对话越来越长,DOM 节点越来越多,内存一直涨,在低端手机或者长时间不刷新的场景下,最终导致页面崩溃。
代码演示
<!-- components/VirtualMessageList.vue -->
<template>
<div
class="virtual-list-container"
ref="containerEl"
@scroll="onScroll"
>
<!-- 撑开总高度的占位 div,让滚动条正确显示 -->
<div class="phantom" :style="{ height: totalHeight + 'px' }" />
<!-- 加载更多历史消息(滚动到顶部触发) -->
<div v-if="isLoadingHistory" class="history-loading">
<span class="spinner" /> 加载历史消息...
</div>
<!-- 实际渲染区域:只渲染可视窗口内的消息 -->
<div
class="render-area"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="msg in visibleMessages"
:key="msg.id"
:data-id="msg.id"
class="message-item"
:class="msg.role"
ref="itemRefs"
>
<MessageBubble :message="msg" />
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import MessageBubble from './MessageBubble.vue'
const props = defineProps({
messages: { type: Array, default: () => [] },
hasMoreHistory: { type: Boolean, default: false }, // 是否还有更早的历史
isLoadingHistory: { type: Boolean, default: false },
})
const emit = defineEmits(['load-more'])
// ==================== 基础配置 ====================
const ITEM_ESTIMATED_HEIGHT = 80 // 消息条目的预估高度(实际测量后会更新)
const BUFFER_COUNT = 3 // 可视区域上下各多渲染 3 条(缓冲,防止滚动白屏)
const LOAD_MORE_THRESHOLD = 100 // 距离顶部 100px 时触发加载更多
// ==================== 状态 ====================
const containerEl = ref(null)
const itemRefs = ref([])
// 缓存每条消息的实际高度(消息内容长短不一,高度不固定)
// key: msg.id, value: 实际高度 px
const heightCache = new Map()
// 当前滚动位置
const scrollTop = ref(0)
// 容器高度
const containerHeight = ref(0)
// ==================== 计算每条消息的位置(top 值)====================
// 根据 heightCache 计算每条消息距离列表顶部的距离
const itemPositions = computed(() => {
let top = 0
return props.messages.map(msg => {
const height = heightCache.get(msg.id) || ITEM_ESTIMATED_HEIGHT
const pos = { id: msg.id, top, height }
top += height
return pos
})
})
// 列表总高度(所有消息高度之和)
const totalHeight = computed(() => {
if (itemPositions.value.length === 0) return 0
const last = itemPositions.value[itemPositions.value.length - 1]
return last.top + last.height
})
// ==================== 计算可视范围内的消息 ====================
// 找到第一条进入可视区域的消息下标
const startIndex = computed(() => {
const scrollY = scrollTop.value
// 二分查找第一个 top + height > scrollTop 的消息
let lo = 0, hi = itemPositions.value.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (itemPositions.value[mid].top + itemPositions.value[mid].height < scrollY) {
lo = mid + 1
} else {
hi = mid
}
}
return Math.max(0, lo - BUFFER_COUNT)
})
// 找到最后一条进入可视区域的消息下标
const endIndex = computed(() => {
const bottom = scrollTop.value + containerHeight.value
let idx = startIndex.value
while (idx < itemPositions.value.length && itemPositions.value[idx].top < bottom) {
idx++
}
return Math.min(props.messages.length - 1, idx + BUFFER_COUNT)
})
// 当前需要渲染的消息切片
const visibleMessages = computed(() => {
return props.messages.slice(startIndex.value, endIndex.value + 1)
})
// 渲染区域的偏移量(用 translateY 把渲染区域移到正确位置)
const offsetY = computed(() => {
return itemPositions.value[startIndex.value]?.top || 0
})
// ==================== 测量实际高度 ====================
// 消息渲染完之后,测量每条消息的真实高度并缓存
// 因为消息内容长短不一,预估高度不准,测量后更新缓存
async function measureHeights() {
await nextTick()
// itemRefs 是当前可视区域的 DOM 节点列表
itemRefs.value.forEach((el, i) => {
if (!el) return
const msgId = visibleMessages.value[i]?.id
const height = el.getBoundingClientRect().height
if (msgId && height > 0 && heightCache.get(msgId) !== height) {
heightCache.set(msgId, height)
}
})
}
// 可视消息变化时重新测量
watch(visibleMessages, measureHeights, { flush: 'post' })
// ==================== 滚动处理 ====================
function onScroll(e) {
scrollTop.value = e.target.scrollTop
// 滚动到顶部附近:加载更多历史消息
if (
e.target.scrollTop < LOAD_MORE_THRESHOLD &&
props.hasMoreHistory &&
!props.isLoadingHistory
) {
emit('load-more')
}
}
// ==================== 新消息到来时自动滚到底部 ====================
watch(
() => props.messages.length,
async (newLen, oldLen) => {
if (newLen > oldLen) {
await nextTick()
scrollToBottom()
}
}
)
function scrollToBottom() {
if (containerEl.value) {
containerEl.value.scrollTop = containerEl.value.scrollHeight
}
}
// ==================== 初始化 ====================
onMounted(() => {
// 记录容器高度
containerHeight.value = containerEl.value?.clientHeight || 0
// 监听容器尺寸变化(窗口缩放时更新)
const ro = new ResizeObserver(entries => {
containerHeight.value = entries[0].contentRect.height
})
ro.observe(containerEl.value)
// 初始滚到底部
scrollToBottom()
onUnmounted(() => ro.disconnect())
})
</script>
<style scoped>
.virtual-list-container {
position: relative;
overflow-y: auto;
height: 100%;
}
.phantom {
position: absolute;
top: 0;
left: 0;
width: 1px;
/* height 由 JS 动态设置,撑开滚动高度 */
}
.render-area {
position: absolute;
top: 0;
left: 0;
width: 100%;
/* translateY 由 JS 动态设置,把内容移到正确位置 */
}
.history-loading {
text-align: center;
padding: 12px;
color: #999;
font-size: 13px;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid #ddd;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
vue
<!-- 父组件:管理分页加载历史消息 -->
<template>
<div class="chat-page">
<VirtualMessageList
:messages="messages"
:has-more-history="hasMore"
:is-loading-history="isLoadingHistory"
@load-more="loadMoreHistory"
/>
<!-- 输入框... -->
</div>
</template>
<script setup>
import { ref } from 'vue'
import VirtualMessageList from '@/components/VirtualMessageList.vue'
const messages = ref([])
const hasMore = ref(true)
const isLoadingHistory = ref(false)
const currentPage = ref(1)
const PAGE_SIZE = 20
// 初始化:只加载最近 20 条
onMounted(async () => {
const recent = await fetchMessages({ page: 1, size: PAGE_SIZE })
messages.value = recent
hasMore.value = recent.length === PAGE_SIZE
})
// 滚动到顶部触发:加载更早的历史消息
async function loadMoreHistory() {
if (isLoadingHistory.value || !hasMore.value) return
isLoadingHistory.value = true
// 记录加载前的第一条消息,加载完后恢复滚动位置
const firstMsgId = messages.value[0]?.id
try {
currentPage.value++
const older = await fetchMessages({ page: currentPage.value, size: PAGE_SIZE })
if (older.length < PAGE_SIZE) hasMore.value = false
// 把历史消息插入列表头部
messages.value = [...older.reverse(), ...messages.value]
// 恢复滚动位置,防止加载后页面跳动
await nextTick()
const firstEl = document.querySelector(`[data-id="${firstMsgId}"]`)
firstEl?.scrollIntoView({ block: 'start', behavior: 'instant' })
} finally {
isLoadingHistory.value = false
}
}
</script>
怎么解决
问题一:滚动卡顿
→ 虚拟列表只渲染可视区域内的消息
→ 100 条消息,实际 DOM 节点始终只有 10-15 个
→ GPU 每帧计算量极小,帧率稳定
问题二:首次加载慢
→ 分页懒加载,进入对话只请求最近 20 条
→ 向上滚动触发加载更早的消息
→ 初始渲染时间从"全量加载"变成"固定 20 条",始终稳定
问题三:内存持续增长
→ DOM 节点数量不随对话增长,始终维持在可视区域的数量
→ 历史消息只存在 messages 数组里(JS 对象),不创建 DOM
→ 内存占用平稳,不会随对话时长线性增长
面试官如果问
为什么不直接用现成的虚拟列表库
可以这样回答
实际项目里我们用的是 vue-virtual-scroller 的 RecycleScroller,它对动态高度支持比较好,消息内容长短不一高度不固定,RecycleScroller 会在首次渲染后记录真实高度,后续滚动用缓存高度定位,不需要自己维护 heightCache 这套逻辑。这里手写是为了 说清楚原理,实际上没必要重复造轮子