14 KiB
14 KiB
消息实时更新和自动滚动修复报告
问题描述
问题1: 发送消息后界面不更新
用户发送消息后,对话框中看不到新消息,需要刷新或切换对话才能看到。
问题2: scrollbarEl.querySelector 错误
控制台报错:
Unhandled Promise Rejection: TypeError: scrollbarEl.querySelector is not a function
问题3: 消息窗口不自动滚动
发送消息后,消息窗口没有自动滚动到底部,用户需要手动滚动才能看到新消息。
根本原因分析
原因1: Vue 响应式系统未触发更新
位置: /web/src/stores/chatStore.ts
问题:
// 原代码
const loadMessages = (topicId: string) => {
state.messages = chatService.getMessages(topicId)
}
chatService.getMessages() 返回的是 conversation.messages 的直接引用:
// chatService.ts
getMessages(topicId: string): Message[] {
for (const conv of this.conversations.values()) {
if (conv.topicId === topicId) {
return conv.messages // ❌ 直接返回引用
}
}
return []
}
问题分析:
- Vue 3 的响应式系统基于 Proxy
- 当
state.messages被赋值为同一个数组引用时,Vue 无法检测到变化 - 即使数组内部元素被修改(push, splice),由于引用未变,组件不会重新渲染
例子:
// 第一次加载
state.messages = conversation.messages // messages 引用: 0x1234
// 发送消息后
conversation.messages.push(newMessage) // 数组内容变了
state.messages = conversation.messages // 但引用还是: 0x1234 ❌ Vue 不更新!
原因2: NScrollbar 使用方式错误
位置: /web/src/components/Chat/ChatLayout.vue 第615行
错误代码:
const scrollToBottom = () => {
nextTick(() => {
if (messagesScrollRef.value) {
messagesScrollRef.value.scrollTo({
top: messagesScrollRef.value.$el.scrollHeight, // ❌ 错误
behavior: 'smooth'
})
}
})
}
问题:
messagesScrollRef.value.$el.scrollHeight获取的是组件根元素的高度,不是滚动容器的高度- Naive UI 的
NScrollbar组件需要特殊的访问方式 - 代码尝试调用
querySelector但该方法在组件实例上不存在
原因3: 滚动时机不对
问题:
- 发送消息后没有立即滚动
- 只在
onChunk回调中滚动,但用户消息已经添加了 - 用户看到自己的消息在上方,需要手动滚动
解决方案
修复1: 强制创建新数组触发响应式更新
文件: /web/src/stores/chatStore.ts
1.1 修改 loadMessages 方法
// 修复前
const loadMessages = (topicId: string) => {
state.messages = chatService.getMessages(topicId)
}
// 修复后
const loadMessages = (topicId: string) => {
// 创建新数组以确保触发响应式更新
state.messages = [...chatService.getMessages(topicId)]
}
效果:
- ✅ 使用扩展运算符
[...]创建新数组 - ✅ 每次调用
loadMessages都会改变数组引用 - ✅ Vue 检测到引用变化,触发组件重新渲染
1.2 修改 sendMessageStream 方法
// 修复前
const sendMessageStream = async (
content: string,
model?: string,
mcpServerId?: string,
onChunk?: (chunk: string) => void
) => {
if (!state.currentTopicId || !content.trim()) return
state.isSending = true
const currentTopicId = state.currentTopicId
try {
await chatService.sendMessageStream(
{...},
(event) => {
// 实时更新消息列表
if (state.currentTopicId === currentTopicId) {
loadMessages(currentTopicId) // ❌ 可能不触发更新
}
...
},
mcpServerId
)
...
} finally {
state.isSending = false
}
}
// 修复后
const sendMessageStream = async (
content: string,
model?: string,
mcpServerId?: string,
onChunk?: (chunk: string) => void
) => {
if (!state.currentTopicId || !content.trim()) return
state.isSending = true
const currentTopicId = state.currentTopicId
// ✅ 立即加载一次消息,显示用户消息
loadMessages(currentTopicId)
try {
await chatService.sendMessageStream(
{...},
(event) => {
// 实时更新消息列表
if (state.currentTopicId === currentTopicId) {
// ✅ 强制创建新数组以触发响应式更新
state.messages = [...chatService.getMessages(currentTopicId)]
}
if (event.type === 'delta' && event.content && onChunk) {
onChunk(event.content)
}
},
mcpServerId
)
// ✅ 最终更新
if (state.currentTopicId === currentTopicId) {
state.messages = [...chatService.getMessages(currentTopicId)]
}
loadTopics()
} finally {
state.isSending = false
}
}
改进点:
- ✅ 在发送前立即加载一次消息(显示用户刚输入的消息)
- ✅ 在每个事件回调中强制创建新数组
- ✅ 消息完成后最终更新一次
修复2: 正确使用 NScrollbar 组件
文件: /web/src/components/Chat/ChatLayout.vue
// 修复前
const scrollToBottom = () => {
nextTick(() => {
if (messagesScrollRef.value) {
messagesScrollRef.value.scrollTo({
top: messagesScrollRef.value.$el.scrollHeight, // ❌ 错误
behavior: 'smooth'
})
}
})
}
// 修复后
const scrollToBottom = () => {
nextTick(() => {
if (messagesScrollRef.value) {
const scrollbarEl = messagesScrollRef.value
// Naive UI NScrollbar 的正确用法
if (scrollbarEl.scrollTo) {
// 方法1: 使用组件的 scrollTo 方法
scrollbarEl.scrollTo({ top: 999999, behavior: 'smooth' })
} else if (scrollbarEl.$el) {
// 方法2: 降级方案 - 直接操作 DOM
const container = scrollbarEl.$el.querySelector('.n-scrollbar-container')
if (container) {
container.scrollTop = container.scrollHeight
}
}
}
})
}
技术细节:
- 方法1: 使用
scrollTo({ top: 999999 })- 足够大的数字确保滚动到底部 - 方法2: 降级方案 - 如果组件方法不可用,直接操作 DOM
- querySelector: 查找
.n-scrollbar-container类(Naive UI 内部结构) - scrollTop: 设置为
scrollHeight确保到达底部
修复3: 优化滚动时机
文件: /web/src/components/Chat/ChatLayout.vue
// 修复前
const handleSendMessage = async () => {
if (!inputText.value.trim() || store.state.isSending) return
const content = inputText.value.trim()
inputText.value = ''
try {
const mcpId = selectedMCP.value === 'none' ? undefined : selectedMCP.value
await store.sendMessageStream(
content,
selectedModel.value,
mcpId,
() => {
scrollToBottom() // ❌ 只在收到AI回复时滚动
}
)
scrollToBottom()
} catch (error) {
message.error(error instanceof Error ? error.message : '发送失败')
}
}
// 修复后
const handleSendMessage = async () => {
if (!inputText.value.trim() || store.state.isSending) return
const content = inputText.value.trim()
inputText.value = ''
// ✅ 发送消息后立即滚动到底部(显示用户消息)
nextTick(() => {
scrollToBottom()
})
try {
const mcpId = selectedMCP.value === 'none' ? undefined : selectedMCP.value
await store.sendMessageStream(
content,
selectedModel.value,
mcpId,
() => {
// ✅ 每次收到消息块时滚动
scrollToBottom()
}
)
// ✅ 消息完成后再滚动一次
scrollToBottom()
} catch (error) {
message.error(error instanceof Error ? error.message : '发送失败')
}
}
滚动时机:
- ✅ 发送前:
nextTick(() => scrollToBottom())- 显示用户消息 - ✅ 流式接收: 每次
onChunk回调时滚动 - 跟随AI回复 - ✅ 完成后: 最终滚动一次 - 确保到达底部
技术原理
Vue 3 响应式系统
// Vue 3 使用 Proxy 实现响应式
const state = reactive({
messages: []
})
// 情况1: 引用相同 - 不触发更新 ❌
const oldRef = state.messages
state.messages = oldRef // 引用没变,不更新
// 情况2: 引用不同 - 触发更新 ✅
state.messages = [...oldRef] // 新数组,触发更新
数组扩展运算符
const original = [1, 2, 3]
const copied = [...original]
console.log(original === copied) // false (不同引用)
console.log(original[0] === copied[0]) // true (元素相同)
特点:
- 浅拷贝数组
- 创建新引用
- 元素本身仍是原引用(对象类型)
- 性能好(只复制引用,不深拷贝对象)
Naive UI NScrollbar 组件结构
<n-scrollbar ref="scrollbarRef">
<!-- 内部 DOM 结构 -->
<div class="n-scrollbar-container">
<div class="n-scrollbar-content">
<!-- 实际内容 -->
</div>
</div>
</n-scrollbar>
API:
scrollTo(options): 滚动到指定位置options.top: 目标滚动位置options.behavior: 'smooth' | 'auto'
$el: 组件根元素 DOM
测试场景
场景1: 发送新消息
- 在输入框输入消息
- 点击发送
- 预期:
- ✅ 立即看到用户消息
- ✅ 自动滚动到底部
- ✅ 看到AI回复逐字出现
- ✅ 滚动跟随AI回复
场景2: 快速连续发送
- 连续发送多条消息
- 预期:
- ✅ 每条消息都立即显示
- ✅ 每次都自动滚动
- ✅ 不会出现消息堆积
场景3: 切换对话
- 在对话A发送消息
- 立即切换到对话B
- 预期:
- ✅ 对话A的消息继续更新(后台)
- ✅ 对话B显示正确的消息列表
- ✅ 切回对话A看到完整对话
场景4: 长消息回复
- 发送一个会产生长回复的消息
- 预期:
- ✅ 消息持续滚动跟随
- ✅ 始终显示最新内容
- ✅ 滚动流畅不卡顿
性能考虑
数组复制开销
// 每次复制整个消息数组
state.messages = [...chatService.getMessages(currentTopicId)]
分析:
- 扩展运算符只复制数组引用,不复制消息对象
- 复杂度: O(n),n 为消息数量
- 典型对话: 20-100条消息
- 开销: 可忽略(< 1ms)
优化建议(如果消息数>1000):
// 使用虚拟滚动
import { VirtualList } from 'naive-ui'
// 或只更新变化的消息
const lastMessageCount = state.messages.length
const newMessages = chatService.getMessages(topicId)
if (newMessages.length !== lastMessageCount) {
state.messages = [...newMessages]
}
滚动频率
// 每次 onChunk 都滚动
onChunk: () => {
scrollToBottom() // 可能每100ms触发一次
}
优化(如果卡顿):
// 节流滚动
let scrollTimer: NodeJS.Timeout | null = null
const throttledScroll = () => {
if (scrollTimer) return
scrollTimer = setTimeout(() => {
scrollToBottom()
scrollTimer = null
}, 100) // 最多100ms滚动一次
}
相关文件
修改的文件
-
/web/src/stores/chatStore.tsloadMessages()- 强制创建新数组sendMessageStream()- 添加立即更新和强制刷新
-
/web/src/components/Chat/ChatLayout.vuescrollToBottom()- 修复 NScrollbar 使用方式handleSendMessage()- 优化滚动时机
依赖关系
ChatLayout.vue
↓ 调用
chatStore.sendMessageStream()
↓ 调用
chatService.sendMessageStream()
↓ 回调
chatStore (event handler)
↓ 更新
state.messages = [...新数组]
↓ 触发
Vue 响应式系统
↓ 重新渲染
ChatLayout.vue (消息列表)
↓ DOM更新后
scrollToBottom()
后续优化建议
1. 虚拟滚动(长对话)
// 使用 Naive UI 的虚拟列表
import { VirtualList } from 'naive-ui'
// 只渲染可见区域的消息
<n-virtual-list
:items="store.state.messages"
:item-size="80"
:item-resizable="true"
>
<template #default="{ item: msg }">
<MessageItem :message="msg" />
</template>
</n-virtual-list>
2. 消息缓存(减少复制)
// 缓存消息数组,只在真正变化时更新
let messageCache: Message[] = []
let messageCacheVersion = 0
const updateMessages = (topicId: string) => {
const newMessages = chatService.getMessages(topicId)
const newVersion = chatService.getMessagesVersion(topicId)
if (newVersion !== messageCacheVersion) {
messageCache = [...newMessages]
messageCacheVersion = newVersion
state.messages = messageCache
}
}
3. 增量更新(仅更新变化的消息)
// 只更新新增或变化的消息
const updateMessagesIncremental = (topicId: string) => {
const newMessages = chatService.getMessages(topicId)
if (newMessages.length > state.messages.length) {
// 只添加新消息
state.messages = [...state.messages, ...newMessages.slice(state.messages.length)]
} else {
// 更新最后一条消息(AI回复)
const lastIndex = state.messages.length - 1
if (lastIndex >= 0) {
state.messages[lastIndex] = { ...newMessages[lastIndex] }
}
}
}
4. 智能滚动(用户可控)
// 检测用户是否在查看历史消息
const isUserScrolling = ref(false)
const shouldAutoScroll = computed(() => {
return !isUserScrolling.value
})
// 滚动时检测
const handleScroll = (e: Event) => {
const container = e.target as HTMLElement
const threshold = 100 // 距离底部100px以内视为"在底部"
const isAtBottom =
container.scrollHeight - container.scrollTop - container.clientHeight < threshold
isUserScrolling.value = !isAtBottom
}
// 只在用户在底部时自动滚动
const smartScrollToBottom = () => {
if (shouldAutoScroll.value) {
scrollToBottom()
}
}
总结
本次修复解决了三个关键问题:
-
响应式更新 ✅
- 使用扩展运算符创建新数组
- 确保Vue检测到变化并重新渲染
-
滚动功能 ✅
- 正确使用 Naive UI NScrollbar API
- 提供降级方案确保兼容性
-
用户体验 ✅
- 消息立即显示
- 自动滚动到底部
- 流畅跟随AI回复
修复后,聊天体验接近主流AI聊天应用(ChatGPT, Claude等)。