
大家好,今天想和大家分享一个我们在开发 AI 多模态聊天应用 时遇到的性能挑战以及解决方案。随着用户使用时间的增加,单个会话的聊天记录可能会积累到成百上千条,这时候性能问题就变得非常突出了。经过一番调研和实践,我们采用了 "后端分页 + 前端虚拟滚动" 的组合方案,效果显著。下面就来详细介绍一下我们的实现思路和遇到的问题。
前言
在开发 AI 聊天应用的过程中,我们发现随着用户使用频率的提高,单个会话的聊天记录会越来越多。最初的实现方式是一次性加载所有历史消息 ,这就导致了两个明显的问题:一是接口响应缓慢 ,用户需要等待较长时间才能看到消息;二是前端渲染时出现明显卡顿,影响用户体验。
为了解决这些问题,我们研究了多种长列表优化方案,最终选择了 "后端分页 + 前端虚拟滚动" 的组合策略。经过实践检验,这个方案不仅显著提升了性能,还保证了良好的用户体验。
问题分析
性能瓶颈在哪里?
在决定优化方案之前,我们首先对现有系统进行了全面的性能分析,发现了两个主要瓶颈:
- 网络传输延迟:一次性传输大量聊天记录导致首字节时间(TTFB)过长,用户需要等待 1-2 秒才能看到内容。
- DOM 渲染压力:每条聊天消息都包含用户输入和 AI 回复,实际渲染的 DOM 节点数量是消息数量的两倍。当消息数量达到数千条时,浏览器的重绘和重排开销变得非常巨大,导致滚动不流畅。
优化目标
基于以上分析,我们制定了明确的优化目标:
- 首屏加载时间控制在 500ms 以内
- 滚动流畅度达到 60fps
- 支持无限加载历史记录
- 整个优化过程对用户体验无感知
技术方案
我们采用了经典的 "分段加载" 策略,结合后端和前端的优势,形成了一个完整的解决方案:
markdown
┌─────────────────────────────────────┐
│ 后端分页 │
│ - 按时间倒序分页查询 │
│ - 默认返回最新 20 条记录 │
│ - 支持向前翻页加载更早消息 │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 前端虚拟滚动 │
│ - 只渲染可视区域的消息 │
│ - 动态测量每条消息的真实高度 │
│ - 滚动到顶部自动加载更多 │
└─────────────────────────────────────┘
这个方案的核心思想是 "按需加载" 和 "按需渲染":后端只返回当前需要的数据,前端只渲染用户当前能看到的内容。
后端实现:基于 Prisma 的智能分页
核心代码实现
我们的后端采用 Node.js + Prisma + MySQL 的技术栈,分页实现的关键在于高效的查询和有用的元数据返回:
javascript
async getSessionDetail(sessionId, userId, queryParams = {}) {
const { page = 1, limit = 20 } = queryParams
// 参数验证和边界处理
const pageNum = Math.max(1, parseInt(page))
const limitNum = Math.min(100, Math.max(1, parseInt(limit)))
const skip = (pageNum - 1) * limitNum
// 并行查询记录和总数,优化性能
const [chatRecords, totalRecords] = await Promise.all([
prisma.chatRecord.findMany({
where: { session_id: sessionId, user_id: userId },
select: {
id: true,
request_text: true,
response_text: true,
think_text: true,
create_time: true,
// ... 其他字段
},
orderBy: { create_time: 'desc' }, // 最新的在前
skip,
take: limitNum,
}),
prisma.chatRecord.count({
where: { session_id: sessionId, user_id: userId },
}),
])
// 计算分页元信息
const totalPages = Math.ceil(totalRecords / limitNum)
const hasNext = pageNum < totalPages
const hasPrev = pageNum > 1
return {
chat_records: formattedRecords.reverse(), // 翻转顺序,让最早的在前
pagination: {
page: pageNum,
limit: limitNum,
total: totalRecords,
total_pages: totalPages,
hasNext, // 前端据此判断是否还有更多数据
hasPrev,
},
}
}
设计要点
1. 为什么倒序查询再翻转?
我们选择先按 create_time desc
排序查询,这样可以利用索引快速获取最新的 N 条记录。查询完成后再通过 reverse()
方法翻转顺序,确保前端拿到的数据是按时间正序排列的(最早的在前),这样更符合用户查看聊天记录的习惯。
2. 分页元数据的妙用
我们发现返回 hasNext
和 hasPrev
比单纯返回 total_pages
更实用:
- 前端不需要自己计算 "是否还有更多数据"
- 避免了总页数变化带来的边界问题
- 减少了前端的逻辑处理
3. 参数边界处理
为了防止恶意请求或不合理的参数导致系统问题,我们添加了参数边界处理:
javascript
const pageNum = Math.max(1, parseInt(page)) // 防止 page < 1
const limitNum = Math.min(100, Math.max(1, parseInt(limit))) // 限制单页最大数量
这是生产环境中非常必要的防护措施。
前端实现:高性能虚拟滚动
虚拟滚动的核心思路
虚拟滚动的本质是 "视口裁剪",也就是说,无论总数据量有多大,我们只渲染当前可视区域内的内容,再加上少量缓冲区内容:
总数据:1000 条消息
可视区:只能看到 5 条
实际渲染:5 + 上下缓冲区 = 15 条
其余的内容用空白占位符来撑开滚动高度,给用户一种所有内容都已加载的错觉。
自研虚拟滚动工具
我们封装了一个 virtualScrollUtil.js
工具,提供了虚拟滚动所需的核心能力:
1. 动态高度测量
聊天消息的高度是不固定的(短消息可能只有几十像素,长消息可能有几百像素),因此需要在运行时动态测量每条消息的实际高度:
javascript
const measureVisible = async () => {
await nextTick()
let changed = false
for (const row of visibleItems.value) {
const el = itemRefs.get(row.id)
if (el) {
const h = el.offsetHeight
if (h && itemHeights.get(row.id) !== h) {
itemHeights.set(row.id, h) // 缓存高度
changed = true
}
}
}
// 高度变化时重新计算可见范围
if (changed) computeVisibleRange()
}
2. 缓冲区机制
为了避免快速滚动时出现白屏现象,我们在可视区上下各增加了一定数量的缓冲行:
javascript
const estRowsPerViewport = Math.max(1, Math.ceil(viewportH / estimateItemHeight))
const buf = Math.max(1, Math.floor(estRowsPerViewport * bufferMultiplier))
start = Math.max(0, start - buf)
end = Math.min(rows.length, end + buf)
经过多次测试,我们发现将 bufferMultiplier
设为 0.75 效果最佳,即缓冲区约为 0.75 个可视区高度。
3. 顶部插入数据不跳动
当用户向上滚动加载更多历史消息时,如果处理不当会导致页面跳动,影响体验。我们的解决方案是:
javascript
// 加载前记住当前滚动高度
const prevScrollHeight = container.scrollHeight
// 插入新数据到顶部
chatMessages.value[sessionId] = [...newMsgs, ...list]
// 加载后恢复滚动位置
await nextTick()
const newScrollHeight = container.scrollHeight
container.scrollTop = newScrollHeight - prevScrollHeight
关键在于计算新增内容的高度差,并将 scrollTop
向下偏移相应的距离,让用户感觉不到内容的变化。
与分页的配合
前端使用 Pinia 来管理每个会话的分页状态:
javascript
// 分页状态管理
const paginationState = ref({
[sessionId]: {
currentPage: 1,
hasMore: false,
isLoading: false,
total: 0,
totalPages: 0,
}
})
// 滚动到顶部时自动加载
const handleScroll = () => {
scroller.onScroll() // 更新虚拟滚动范围
const container = messagesContainer.value
const nearTop = container.scrollTop <= 40
if (nearTop && pagination.value?.hasMore && !isLoadingMore.value) {
loadOlder() // 加载更早的消息
}
}
当用户滚动到距离顶部 40px 时,自动触发加载更早的消息。这个阈值经过多次测试,既能保证及时加载,又不会过于频繁地触发请求。
性能优化效果
优化前后的对比数据非常显著:
指标 | 优化前 | 优化后 | 提升 |
---|---|---|---|
首屏加载时间 | 1.8s | 0.3s | 83% ↓ |
首屏渲染 DOM 数量 | 2000+ | 40 | 95% ↓ |
滚动 FPS | 30-40 | 55-60 | 50% ↑ |
内存占用(1000 条消息) | ~180MB | ~45MB | 75% ↓ |
这些改进直接转化为了更好的用户体验,特别是在消息量较大的会话中,用户可以流畅地浏览历史记录。
踩过的坑
在实现这个方案的过程中,我们遇到了不少问题,这里分享几个主要的 "坑":
坑 1:去重问题
由于采用分页加载,当用户快速操作时可能会导致重复加载相同的数据。我们的解决办法是使用 Set 存储已加载的记录 ID:
javascript
// 使用 Set 存储已加载的记录 ID
const seenRecordIdsMap = ref({})
const getSeenSet = (sessionId) => {
if (!seenRecordIdsMap.value[sessionId]) {
seenRecordIdsMap.value[sessionId] = new Set()
}
return seenRecordIdsMap.value[sessionId]
}
// 加载时过滤重复数据
data.chat_records.forEach((record) => {
if (seen.has(record.id)) return // 已存在,跳过
seen.add(record.id)
// ... 添加到消息列表
})
坑 2:会话切换时的状态清理
不同会话之间的分页状态是独立的,切换会话时需要注意:
- 不要清空已缓存的消息数据(用户可能会切回来)
- 每个会话的分页状态要独立存储
- 虚拟滚动范围要重新计算
坑 3:新消息到达时的滚动策略
当有新消息到达时,我们希望只有在用户当前浏览到列表底部时才自动滚动到底部,否则会打断用户查看历史消息:
javascript
const isNearBottom = () => {
const c = messagesContainer.value
if (!c) return false
const delta = c.scrollHeight - (c.scrollTop + c.clientHeight)
return delta <= 80 // 距底部 80px 内才自动滚动
}
watch(currentMessages, () => {
if (isNearBottom()) {
scrollToBottom()
}
}, { deep: true })
总结
通过 后端分页 + 前端虚拟滚动 的组合方案,我们成功将长列表性能提升了一个数量级。这套方案的核心在于:
- 按需加载:只加载用户需要看的数据
- 按需渲染:只渲染用户能看到的 DOM
- 智能预加载:缓冲区机制保证滚动流畅
- 体验优先:无感知的数据加载和状态切换
如果你的应用也面临长列表性能问题,不妨试试这套方案。我们的代码已经在生产环境稳定运行了一段时间,效果良好。
技术栈:Vue 3 + Pinia + Vite + Node.js + Prisma + MySQL
源码参考:
- 虚拟滚动工具:
virtualScrollUtil.js
- 后端分页服务:
sessionService.js
- 前端集成示例:
ChatView.vue
多模态Ai项目全流程开发中,从需求分析,到Ui设计,前后端开发,部署上线,感兴趣打开链接(带项目功能演示) ,多模态AI项目开发中...
希望这篇分享对你有所帮助,如果你有更好的想法或遇到了什么问题,欢迎在评论区交流讨论!