返回笔记首页

长对话场景性能优化

主题配置

问题

正常的列表渲染,有多少条消息就创建多少个 DOM 节点。对话轮次少的时候没问题,但客服场景里用户可能聊几十上百轮,每条消息里还有代码块、引用卡片这些复杂结构,DOM 节点数量会非常多。

具体会出现三个问题:

问题一:滚动卡顿

100 条消息,每条消息平均 10 个 DOM 节点,就是 1000 个节点在页面上。用户滚动的时候浏览器要计算所有节点的位置,帧率直接掉下来,滑起来一顿一顿的。

问题二:首次进入历史对话慢

用户重新打开一个聊了 80 轮的对话,前端一次性把 80 条消息全部渲染出来,页面要卡好几秒才能响应,用户以为页面崩了。

问题三:内存持续增长

对话越来越长,DOM 节点越来越多,内存一直涨,在低端手机或者长时间不刷新的场景下,最终导致页面崩溃。

代码演示
vue
<!-- 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

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>

怎么解决

plain
问题一:滚动卡顿
  → 虚拟列表只渲染可视区域内的消息
  → 100 条消息,实际 DOM 节点始终只有 10-15 个
  → GPU 每帧计算量极小,帧率稳定

问题二:首次加载慢
  → 分页懒加载,进入对话只请求最近 20 条
  → 向上滚动触发加载更早的消息
  → 初始渲染时间从"全量加载"变成"固定 20 条",始终稳定

问题三:内存持续增长
  → DOM 节点数量不随对话增长,始终维持在可视区域的数量
  → 历史消息只存在 messages 数组里(JS 对象),不创建 DOM
  → 内存占用平稳,不会随对话时长线性增长
面试官如果问

为什么不直接用现成的虚拟列表库

可以这样回答

实际项目里我们用的是 vue-virtual-scrollerRecycleScroller,它对动态高度支持比较好,消息内容长短不一高度不固定,RecycleScroller 会在首次渲染后记录真实高度,后续滚动用缓存高度定位,不需要自己维护 heightCache 这套逻辑。这里手写是为了 说清楚原理,实际上没必要重复造轮子