1. 前两篇打了地基,这一篇让机器人开口
系列写到第三篇,总算要进入最有意思的部分了。用Vue实现小Q聊天机器人,前两篇我们把项目骨架搭起来,消息列表能渲染了,基础布局也调得像模像样。但如果你看完前两篇跟着做了一遍,会发现页面是静态的:输入框敲了字,点发送按钮,什么都没有发生。这很正常,因为前两篇解决的只是「看起来像个聊天界面」,从「看起来像」到「真的能聊」,中间正好隔着一整篇的内容。
这一篇我打算把交互链路完整走一遍:用户输入消息后,消息怎么进列表、机器人怎么模拟正在输入、回复怎么追加到对话里、自动滚动怎么做到不抖动、消息组件怎么拆分才方便后续接真实接口、接到一半接口挂了怎么办。这些点单独看不难,但串在一起的时候,细节非常容易翻车。我尽量把自己踩过的坑都标出来,你照着写的时候能少走两步弯路。
1.1 从静态展示到动态交互,需要跨过四个坎
先捋一下我们要解决的问题,其实就是四个:
- 消息数据从「写死的常量」变成「一个动态增长的数组」,发送消息的本质是往数组尾部push一项。
- 输入框要受控,回车和点击两种发送方式都要处理,还要处理快速连按导致的重复发送。
- 机器人回复不能「瞬移」出来,得有一个模拟思考/打字的过程,视觉上更自然。
- 消息列表长度变化后,要让可视区域自动滚到最新一条,但又不能在用户上滑查看历史时强行拉回底部。
这四个坎弄完,一个本地版聊天机器人就活了。后面接API,本质只是把「模拟回复」替换成「异步请求」,数据流不变。
1.2 消息数据结构一开始就要定好,不然改起来全是泪
我见过不少同学在demo阶段图省事,消息就存一个字符串数组,结果做到后面要么重新建对象,要么用一堆正则去解析字符串,非常痛苦。消息列表里每条消息至少需要这几个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string/number | 唯一标识,用于v-for的key和后续diff |
| role | 'user' / 'bot' | 区分左侧还是右侧气泡 |
| content | string | 消息文本 |
| time | number | 时间戳,用于时间分组和排序 |
| status | string | 发送中/成功/失败,或bot的「思考中」状态 |
id不能省。如果你用数组下标当key,等插入一个「正在输入」的占位消息时,后面所有消息的key都会变,轻则组件状态错乱,重则直接滚动异常。time用时间戳而不是格式化字符串,格式化放到渲染时做,这样排序和比较都方便。
我自己的模拟项目X里,初始数据大概是这样的:
const initialMessages = [ { id: 'm_1', role: 'bot', content: '你好,我是小Q,有什么可以帮你?', time: Date.now() - 1000 * 60 * 5, status: 'success' } ]这条初始消息作用很大:一是让首屏不空,二是给用户一个视觉锚点,知道机器人已经在线。很多聊天页会放一条欢迎语,不是因为技术需要,而是产品体验的需要。
2. 发送消息与机器人回复的状态流设计
2.1 输入框事件处理:比v-model和回车多一点细节
很多教程一上来就给你一个v-model="inputText"加@keyup.enter="sendMessage",看着没问题,但实际跑起来有几个隐患。
第一个是中文输入法。在拼音输入过程中按回车本来是要确认候选词,结果keyup.enter也会触发发送,用户还没选好词就把消息发出去了。解决办法是判断event.isComposing,如果是输入法组合状态就忽略:
<input v-model="inputText" @keyup.enter="handleSend" @compositionstart="isComposing = true" @compositionend="isComposing = false" >function handleSend(event) { // event.isComposing 某些浏览器在组合中可能不返回,所以配合手动标记更稳 if (event.isComposing || isComposing) { return } sendMessage() }这个坑在中文产品里特别常见,尤其是搜狗、微软拼音这类输入法。我第一次做的时候没管这个,测试同学用拼音输入回车直接发了半截话,被提了bug才意识到。
第二个是发送前要trim。用户如果只敲了个空格,理论上不应该发出气泡。发送后清空输入框,同时把input的焦点拉回来,不然用户连续发送时每次都要拿鼠标点输入框,体验很碎片。清空和焦点一般在nextTick里做,因为你改了inputText之后DOM还没来得及更新就直接focus(),有可能失败的。
function sendMessage() { const content = inputText.value.trim() if (!content) return pushMessage({ role: 'user', content, status: 'success', time: Date.now() }) inputText.value = '' nextTick(() => { inputRef.value?.focus() }) triggerBotReply(content) }2.2 用一个「状态机」管理回复节奏,而不是无脑setTimeout
机器人回复最常见的方法就是setTimeout(() => pushBotMessage(), 1000)。小demo没问题,但一旦后面要接入真实接口,这种写法会让代码变得很难维护。我建议从开始就画三条状态:idle(空闲)、waiting(等待回复)、typing(正在输出)。
流程是这样的:用户发送消息 -> 状态改为waiting-> 列表里出现一个「正在输入」的占位气泡 -> 模拟接口1.2秒后返回 -> 状态改为typing-> 再等0.5秒逐字输出或直接整体输出 -> 状态改回idle。
为什么要加typing状态?因为单纯的等待让人烦躁,而typing的视觉反馈能有效降低等待感。我自己做小Q的时候,在占位气泡里放了三个跳动的点,JS动画写着麻烦,直接用了CSS动画,效果一样:
<div class="typing-indicator" v-if="false"> <span></span><span></span><span></span> </div>.typing-indicator span { display: inline-block; width: 6px; height: 6px; margin-right: 4px; background: #999; border-radius: 50%; animation: typingBounce 1.2s infinite ease-in-out; } .typing-indicator span:nth-child(2) { animation-delay: 0.2s; } .typing-indicator span:nth-child(3) { animation-delay: 0.4s; } @keyframes typingBounce { 0%, 60%, 100% { transform: translateY(0); opacity: 0.6; } 30% { transform: translateY(-6px); opacity: 1; } }状态机的价值在接入真实API后会体现出来。请求发出去了,你不可能原地等,得把waiting态暴露给UI层,按钮禁用、loading态展示、超时处理全都有了着落。我见过有人用大量setTimeout堆叠回复,等接口一接进来,根本不知道该取消哪个定时器,最后直接重构。
所以推荐维护一个更轻的定时器引用来控制取消:
let botTimer = null function triggerBotReply(userText) { botState.value = 'waiting' // 先插入一个占位消息,表示机器人正在输入 const tempMsg = { id: 'temp_typing', role: 'bot', status: 'thinking', content: '' } pushMessage(tempMsg) clearTimeout(botTimer) botTimer = setTimeout(() => { // 真正的回复内容,这里先用一个规则生成 const reply = generateReply(userText) // 把临时占位消息替换成真实消息 replaceMessage('temp_typing', { ...tempMsg, id: `bot_${Date.now()}`, content: reply, status: 'success', time: Date.now() }) botState.value = 'idle' }, 1200 + Math.random() * 600) }注意发送新消息前先clearTimeout,避免用户连续发两条的时候第一条的定时器把回复顺序搞乱。真实聊天场景里,先提问的人应该先得到回复,后提问的人后得到回复,顺序乱了会非常出戏。
2.3 自动滚动到底:scrollTop被“截胡”的两个原因
消息多了以后,滚动是绕不开的问题。常规写法是监听消息数组长度的变化,然后设置scrollTop = scrollHeight。但真跑起来你会发现滚动经常不到位,原因基本是这两个。
第一个是DOM还没更新完。pushMessage之后,v-for的渲染是异步的,你立刻去读scrollHeight拿到的是旧值。解决办法很简单,用nextTick:
import { nextTick } from 'vue' async function scrollToBottom() { await nextTick() const listEl = listRef.value if (listEl) { listEl.scrollTop = listEl.scrollHeight } }第二个是气泡里的图片或自定义字体导致的。文本渲染可能已经完成了,但一张图片没有加载完,图片高度撑开了滚动区,scrollHeight又变了。遇到网络图片的头像最容易出这个问题。小Q的默认头像是本地base64或SVG,不受影响;如果你后续接了用户上传的头像URL,就得在图片的load事件里再触发一次滚动。
还有个小细节:自动滚动应该在用户「离底部不远」的时候才生效。如果用户正在上滑看历史消息,你硬生生把他拽回底部,体验是很崩溃的。我加了一个判断:距离底部超过80px就不自动滚,只有用户本来就在底部附近才跟随。
function isNearBottom() { const listEl = listRef.value return listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight < 80 }这个80px可以根据你的气泡大小调,做得好的聊天应用会把这个阈值设成“最后一条消息高度的一半”,这样哪怕用户看到最后一条消息的一半,新消息进来也能顺滑地往下接。
3. 从“一个组件画所有气泡”到“消息子组件拆分”
3.1 为什么必须把聊天气泡拆成单独组件
如果你只有一个ChatMessage.vue,里面用v-if判断user还是bot来渲染不同样式,前期是方便,但后期维护会越来越难受。因为用户消息和机器人消息的差异,不只是左右排列,还有这些点:
- 用户消息可以用深色背景、白色文字,机器人用浅色背景、深色文字。
- 用户消息通常靠右,气泡圆角不对称。
- 机器人消息可能需要展示「操作按钮」「链接」「卡片」,用户消息一般纯文本。
- 机器人消息有
thinking状态,用户消息有sending、failed状态。
把这些全部塞进一个组件,那就是一个长长的v-if链条。更合理的是拆分:
UserMessage.vue负责用户消息展示。BotMessage.vue负责机器人消息展示。- 父组件
MessageList.vue只负责遍历,根据role选择子组件。
我用一个动态组件来选,编码上很干净:
<template> <component :is="message.role === 'user' ? UserMessage : BotMessage" :message="message" /> </template>3.2 气泡内容能不能用v-html?千万别
很多新手拿到消息文本喜欢直接v-html,方便支持富文本。但聊天窗口是最容易被注入的入口之一,如果消息来自用户输入或者第三方接口,v-html打开的XSS漏洞会被人利用。这里提醒一下:永远不要去渲染你无法完全信任的HTML。
小Q的回复暂时没有富文本需求,所以我直接用textContent风格的渲染。但文本里有换行,直接放在<p>里会被合并成空格。需要一个简单的处理方法:把\n替换成<br>,同时把特殊字符转义。这里不用v-html,而是用<pre>或CSS的white-space: pre-wrap,更安全也更简单。
.message-content { white-space: pre-wrap; word-break: break-word; }这样\n会正常换行,http://开头的URL也不会被自动截断,长单词会自动折行。比手动拼接<br>更优雅。
3.3 时间分组与日期分割线:聊天记录的高级感
如果每一条消息都带时间,界面会非常啰嗦。主流聊天应用的做法是:两条消息间隔超过5分钟,才在中间插入一条时间提示;跨天的时候插入日期。我实现了一个简单的函数,在遍历消息时动态判断:
function shouldShowTime(prev, current) { if (!prev) return true const diff = current.time - prev.time return diff > 5 * 60 * 1000 // 超过5分钟 } function shouldShowDate(prev, current) { if (!prev) return true const prevDay = new Date(prev.time).toDateString() const currentDay = new Date(current.time).toDateString() return prevDay !== currentDay }然后在MessageList中遍历时,把这两个判断放在v-for之前。我自己的做法是生成一个viewModels计算属性,把原始消息数组展开,遇到需要时间组的时候就插入一个{ type: 'time' }的占位对象,这样模板里就不用堆一堆逻辑了。
这些细节看着不起眼,但决定了聊天机器人demo是「能用」还是「像真的产品」。我拿给朋友看的时候,他第一反应就是“哎,它知道按时间分开了”,这种感知层面的好评,往往比功能本身更有说服力。
4. 接入真实问答接口时,数据流要怎么改
4.1 从Mock到Axios,总共只需要改三个地方
小Q本地版可以内置一套规则回复,比如你说“你好”它回“你好呀”,但一个聊天机器人最终还是要接真实问答接口。从Mock到真实接口,我实践下来只需要动三个地方:
- 把
generateReply函数替换成requestReply异步函数。 triggerBotReply里不再setTimeout,而是await请求。- 新增请求期间的状态管理:禁用发送按钮或展示加载态。
代码结构可以保持几乎不变,这是前面强调状态机的好处。我封装了一个api.js:
import axios from 'axios' export function requestReply(payload) { return axios.post('/api/chat', payload, { timeout: 10000 }).then(res => res.data) }然后在triggerBotReply里这样用:
const botResponse = await requestReply({ text: userText, sessionId })这样改造之后,之前模拟的1.2秒延迟自然消失了,变成了真实的网络延迟。要注意的是:不要忘记给请求加timeout,否则接口一直不返回,小Q会永远停留在waiting状态。
4.2 请求期间的UI状态与并发保护
并发请求这个问题,很多人一开始注意不到。用户连续发两条消息,请求A发出去了,还没回来,用户又发了请求B。这时候有两个情况要处理。
第一个是机器人回复顺序。如果A比B慢,A会覆盖B的位置,导致对话错乱。解决办法是给每一个请求带一个序号,或者直接采用最简单的策略:机器人还在回复的时候,禁止用户发送新消息。我倾向于禁止发送,因为一个本地聊天机器人demo里,多并发场景不是重点,禁止后体验也很自然。
实现起来很简单,waiting状态就让输入框的发送按钮禁用,回车也返回。
<button :disabled="botState !== 'idle'" @click="sendMessage">发送</button>同时在输入框的@keyup.enter里判断状态。
第二个是组件卸载时请求才返回的问题。如果你的页面支持切换会话,很可能会在请求回来后尝试更新一个已经卸载的组件,Vue会警告。处理方法是加一个isMounted标记或使用AbortController。我用一个简单的标志:
let disposed = false onUnmounted(() => { disposed = true }) // 在请求完成时 if (!disposed) { // 更新消息列表 }4.3 接口失败后,如何假装“小Q没掉线”
真实接口一定会失败,这是宿命。测试弱网环境或者把后端服务停掉,你的聊天窗口就会卡在一个呆滞的waiting状态。我踩过一次,用户发消息,等了三秒没回,再发也没反应,看起来就是死掉了。
正确的做法是:请求异常时,把那条thinking占位消息替换成一条报错消息,同时把状态恢复成idle,并且允许用户点击「重试」。报错消息不要用刺眼的红色,那样像系统崩溃,我用了暖色底纹加一句“网络开小差了,点我重试”。
function handleRequestError(tempMsg, errorMessage) { replaceMessage(tempMsg.id, { ...tempMsg, id: `error_${Date.now()}`, content: errorMessage || '小Q走神了,请稍后再试', status: 'error' }) botState.value = 'idle' }点击这条消息重新触发请求,前提是事件委托或子组件发事件。给BotMessage加一个点击事件,当message.status === 'error'时执行重试。这样用户不会觉得机器人“死了”,而是知道是网络问题,他有掌控感。
5. 上下文管理与性能优化:别等卡了再改
5.1 历史消息数组无限制增长的隐患
聊天页如果一直开着,消息数组会只增不减。一次会话聊几百条,数据量对渲染来说倒还好,但如果每条消息内容很长,再结合DOM节点数量,列表会逐渐卡顿,尤其在低端手机上。
一个简单有效的策略是限制最多保留100条消息,超出后丢弃最前面的老消息。很多人担心丢上下文,但对于一个demo机器人,保留最近几十条足够维持对话氛围。实现也不难:
const MAX_MESSAGES = 100 function pushMessage(message) { messages.value.push(message) if (messages.value.length > MAX_MESSAGES) { messages.value.splice(0, messages.value.length - MAX_MESSAGES) } }注意splice(0, k)是一次性删除前k条,不要用shift()一条条删,那样会触发多次响应式更新,性能更差。
5.2 v-for的key为什么不能用index:一个真实例子
前两篇里如果你用的是index当key,那在这一篇一定会遇到问题。我做一个「撤回消息」功能时,发生了很诡异的现象:撤回第一条消息,结果最后一条消失了。
原因是Vue的diff算法依赖key来识别节点。用index当key,删除中间一条,后面所有消息的key都会变,Vue会把它们当成新节点重新创建,而且会错误地复用DOM状态。气泡本身是纯展示还好,但如果消息里有正在播放的语音、有输入框、有图片懒加载状态,就会错乱。
所以从一开始就定下规矩:每条消息用id当key。这个id我直接用Date.now() + 随机数生成,虽然理论上可能碰撞,但demo够了。如果你需要更严谨,可以引入uuid。
5.3 用localStorage缓存最近会话,刷新页面不丢记录
聊天机器人最怕刷新一下,上下文全没了。我加了一个简单的持久化:把消息数组缓存到localStorage,每次pushMessage和replaceMessage之后调用一次saveToStorage。读取时放在onMounted里。
const STORAGE_KEY = 'xiaoq_chat_history' function saveToStorage() { localStorage.setItem(STORAGE_KEY, JSON.stringify(messages.value)) } function loadFromStorage() { const raw = localStorage.getItem(STORAGE_KEY) if (raw) { try { const parsed = JSON.parse(raw) if (Array.isArray(parsed)) { messages.value = parsed return } } catch (e) { // 数据损坏时直接忽略,用默认值 } } messages.value = initialMessages }这里有个坑:localStorage里存的是序列化后的字符串,时间戳是数字,所以没问题。但如果你后面存了thinking状态的临时消息,千万别把它也存进去,否则用户刷新后看到一条永远在转圈的假消息。我在saveToStorage里过滤掉status === 'thinking'的条目。
缓存不宜存太多,小Q的会话我只存最近50条,不然超出localStorage的5MB配额会把浏览器撑爆。
最后分享几个这几次迭代里踩出来的经验
用Vue做小Q聊天机器人,做到第三篇,我最大的感受是:聊天界面的技术难点不在「能发消息」,而在「细节的稳定性」。比如自动滚动差几像素、输入法冲突、请求失败后状态卡死、数组key用错导致的幽灵消息——每个问题单独拿出来都不大,但叠在一起足以让人对项目失去信心。
我个人习惯是在代码里显式声明消息状态机,把idle、waiting、typing、error写在注释里,这样过两周回来改代码,还能立刻想起来当时的业务流程。另外,所有异步操作(定时器、请求、滚动)都要考虑组件卸载的情况,否则页面切到别的路由再切回来,控制台会冒出莫名其妙的警告。
还有一个小技巧:开发阶段可以把模拟回复的延迟调成0.3秒,测试效率会高很多;等联调阶段再把延迟恢复成自然值。你甚至可以写一个调试开关,用query参数控制是走Mock还是走真实接口,这样前端和后端可以并行开发互不阻塞。
后面如果再往下写,我会考虑接入语音输入、消息多模态、会话列表这些方向。但就目前第三篇做完的效果来看,小Q已经从一个「静态页面」变成了一个「有生命感的NPC」,至少我自己在测试的时候会真的想跟它多聊两句。对话体验这件事,花在细节上的每一分钟都是值得的。