资讯动态

Vue 3组合式API打造高性能AI聊天页面:SSE流式输出与状态管理实践

发布时间:2026/10/17 10:52:24 来源:尧图企业网站定制
简介基于Vue 3开发的AI聊天页面完整前端项目面向具备一定Vue基础、希望快速上手AI对话界面开发的初中级前端工程师解决从零搭建聊天交互UI与状态管理的实际需求。压缩包共79个文件以Vue组件、JavaScript脚本、JSON配置及HTML页面为主另含sample示例、png图片与markdown说明文档附带项目配置文件及Git版本管理元数据整体仅668KB目录结构清晰便于按模块快速定位和学习。目前已有1110人学习/下载适用于学习Vue 3组合式API、组件通信、路由与状态管理等核心知识点也可作为AI聊天前端实现的参考样例。价值在于提供完整项目骨架涵盖组件拆分、路由配置、视图组织与集中式状态管理并配有示例文件辅助理解关键逻辑可直接在本地运行调试或改造复用帮助开发者快速掌握从界面布局到AI交互的完整实现思路。1. 从0搭一个AI聊天页为什么先选Vue 3而不是死磕原生JS某开发团队要做一个内部AI问答工具页面要支持多轮对话、流式打字机、多会话历史记录还要能塞进现有的移动端H5壳子里。某开发者一开始用Vue 2的Options API写了第一版消息列表每次流式更新都触发整页重渲染打字机效果一卡一卡状态散落在data、computed和eventBus里完全没法维护。后来整页用Vue 3组合式API重写一条useChatPanel把消息、状态、会话管理全部收敛问题才真正落地。这篇文章就是那次重写后的完整记录从消息状态设计、SSE流式接入、会话持久化到移动端踩坑照着走能直接搭出一个可用的vue.js聊天页面。适合正在做人工智能前端页面、想把聊天消息流和会话管理一次做对的人。2. 消息链路设计Composition API状态、渲染与五类边界消息2.1 状态模型为流式消息预留七个字段AI聊天页面和普通列表页最大的区别在于消息是持续变动的对象用户发出文本后要立刻显示一条user消息然后生成一条空的assistant消息占位后端数据以流式片段不断追加到这条消息的content上期间还要经历pending、running、error三种状态。如果像Vue 2时代那样把每条消息当做一个松散的data字段来管写不到一半就乱了。我一般会把消息状态收敛到一个组合式函数里每条消息固定七个字段后续所有逻辑只有这七个字段可以写不会出现“多一个变量少一个变量”的失控感。// src/composables/useChatPanel.js import { ref } from vue import { createId, formatTime } from ../utils export function useChatPanel() { const messages ref([]) const sending ref(false) const pushMessage (role, content, extra {}) { const msg { id: extra.id || createId(), role, // user | assistant | system content: String(content), createdAt: extra.createdAt || Date.now(), status: extra.status || done, // pending | running | done | error conversationId: extra.conversationId || null, raw: extra.raw || null } messages.value.push(msg) return msg } return { messages, sending, pushMessage } }这里把id单独生成而不是用数组下标当key后面加虚拟滚动、插入历史消息时不会错位。role和status都设计成固定枚举值渲染层直接根据值切样式而不是用if-else判断字符串内容。createdAt存时间戳不在状态里存格式化后的字符串——这一点很多人忽略一旦消息多了格式化后的字符串占据的内存比你想的大。conversationId在单会话页面可能用不到但一旦做多会话切换这个字段就是消息归属的关键索引提前留好绝对不亏。为什么不建议用reactive({ list: [] })包一层在组合式函数里ref和reactive都能用但流式追加消息时reactive对深层嵌套对象的依赖收集开销更高而且解构后容易丢失响应性。用ref存数组配合push操作渲染层的更新粒度更可控。pushMessage慎用默认参数。这里我刻意让id和createdAt都必须由调用方显式传入是为了在多设备同步、导入导出历史记录时消息的id和时间戳不会因为重放而改变。单元测试也方便直接构造固定消息对象。2.2 消息渲染组件代码块折叠、长文本换行与角色区分状态设计好之后渲染层是第二个关键。AI回复里最常见的是代码块和超长段落如果直接用纯文本渲染聊天页面会变成一坨没有边界的文本流。我用的方案是轻量处理不引重型Markdown渲染依赖只拆代码块其余文本做HTML转义后换行输出。!-- src/components/ChatMessage.vue -- template div classmsg :classmsg--${msg.role} div classmsg__head span{{ msg.role user ? 你 : AI }}/span time{{ formatTime(msg.createdAt) }}/time i v-ifmsg.status running生成中/i i v-else-ifmsg.status error出错/i /div div classmsg__body v-htmlsafeHTML(msg.content)/div /div /template script setup import { computed } from vue import { escapeHTML, formatTime } from ../utils const props defineProps({ msg: { type: Object, required: true } }) const safeHTML computed(() { const html escapeHTML(props.msg.content) return html .replace(/([\s\S]*?)/g, precode$1/code/pre) .replace(/\n/g, br) }) /scriptescapeHTML这一步不是可选项。AI接口返回的内容是外部输入直接把content丢进v-html等于给XSS开了一扇门。escapeHTML把script变成纯文本后面的正则再做代码块识别顺序不能反先转义、后匹配否则代码块内容也被转义了。样式上我会给.msg__body里的pre设置max-height: 320px; overflow: auto代码超过八行就出了滚动条。普通文本节点用word-break: break-all避免一个超长URL把整个布局撑破。2.3 边界消息处理空消息、超长消息、连续消息与代码复制聊天场景的边界消息比普通列表多第2章这里单独说。第一类是空消息AI偶尔会返回零长度content消息体区域会渲染成一个空白div视觉上像页面漏了块。处理方式是在组件里判断content.trim()为空时不渲染body只保留head。第二类是超长消息比如把整个日志文件扔给AI总结返回可能超过一万字body需要直接限高否则页面会被拉出几百屏。第三类是连续相同角色消息部分后端会把一次回复拆成多段SSE事件并各自落库前端会看到两条挨着的AI消息中间没有用户提问。我一般会合并展示渲染时若相邻两条都是assistant且时间差小于5秒后一条不再显示head只显示body。这个合并逻辑放在computed里做不改动底层messages数组避免破坏原始数据。第四类是代码复制很多人到这个阶段才会发现需求界面渲染出代码块用户想复制还得手动选中碰到长代码直接劝退。我会给pre右上角加一个复制按钮通过navigator.clipboard.writeText写入失败时退回document.execCommand(copy)。这里有个坑非安全上下文http环境下navigator.clipboard不可用要用try-catch捕获错误再做回退。第五类是markdown标题与列表不做完整解析但可以把##开头的内容加粗把-开头的内容做缩进成本极低视觉层次却能拉开。3. 流式输出SSE接入、打字机效果与中断处理3.1 轮询、WebSocket与SSE的选型理由AI聊天页面的核心体验就是打字机效果这依赖前端是否能拿到流式数据。常见的三种方案轮询、WebSocket、SSE。轮询最简单定时fetch一次但AI回复动辄几十秒轮询间隔和延迟天然矛盾来回的HTTP开销也大不推荐。WebSocket是双向全双工能力最强但它的心智负担和运维成本也最高需要处理连接保活、心跳、重连、粘包、二进制帧服务端还要额外维护长连接池。对于一个以问答为主、用户主动发起的场景WebSocket属于杀鸡用牛刀。SSEServer-Sent Events是单向服务端推送正好匹配AI聊天的通信模型客户端POST一条消息服务端把回复内容拆成大小不等的chunk持续推送过来。SSE基于HTTP天生兼容现有网关、负载均衡断线重连由浏览器EventSource处理。但它有个限制EventSource只支持GET不能自定义请求头而AI接口一般要带Authorization和对话上下文。所以我的做法是绕开EventSource直接用fetch读取ReadableStream手动解析SSE格式。方案通信方向握手成本重连机制适合场景轮询单向请求高无不推荐WebSocket双向高需自实现实时交互、双向推送EventSource服务端单向低浏览器内建纯推送、无需自定义头fetch ReadableStream服务端单向低自实现需要自定义请求头、流式解析3.2 fetch流式读取逐字追加的实现核心代码是把fetch返回的Response.body通过ReadableStream逐个chunk读出来再按SSE的data:前缀解析。这一步是整个聊天页面体验的胜负手写错一个字符整个流式效果就变成一次性吐出一大段。// src/services/streamChat.js export async function streamChat({ messages, onDelta, onDone, onError, signal }) { const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, stream: true }), signal }) if (!resp.ok) throw new Error(HTTP ${resp.status}) const reader resp.body.getReader() const decoder new TextDecoder(utf-8) let buf while (true) { const { done, value } await reader.read() if (done) break buf decoder.decode(value, { stream: true }) // 按换行拆解最后一行可能不完整留到下一轮再处理 const lines buf.split(\n) buf lines.pop() for (const line of lines) { const trimmed line.trim() // 兼容 SSE 格式只取 data: 开头的事件 if (!trimmed.startsWith(data:)) continue const payload trimmed.slice(5).trim() if (payload [DONE]) { onDone?.() return } try { const json JSON.parse(payload) const delta json.choices?.[0]?.delta?.content || if (delta) onDelta?.(delta) } catch (err) { // 解析失败说明服务端返回了不完整 JSON跳过这一行 console.warn(stream parse error:, err) } } } onDone?.() }参数说明messages是发送给后端的多轮历史signal是AbortController实例的signal用来中断请求。onDelta在每次拿到增量内容时回调onDone在流结束或收到[DONE]标记时回调onError抛错。decode(value, { stream: true })是必须写的。不带stream参数时如果某个UTF-8字符被拆在两个chunk里会出现中文乱码。stream模式会把残留字节缓存到内部下一轮解码时补全。按行拆解的逻辑是SSE解析的经典做法TCP层不会保证每一条事件都在同一个chunk里服务端可能一次flush多条data也可能一条data被拆成两半。buf变量就是为此设计的——每轮循环只处理完整行残留的半行留到下一轮继续拼接。3.3 中断、重试与错误兜底流式读取最容易被忽视的是中断。用户等得不耐烦点了”停止“或者页面切换走了如果fetch还在后台跑回调函数会照常执行甚至可能把最后一次的delta追加到已卸载的组件上。标准做法是引入AbortController。// 发送流式请求时挂一个控制器 const controller new AbortController() streamChat({ messages: history.value, signal: controller.signal, onDelta: (txt) { appendDelta(txt) }, onDone: () { markDone() } }) // 用户点停止时 const stop () controller.abort()中断后fetch会抛AbortError需要在onError里判断错误名是AbortError还是普通网络错误。前者是用户主动中止消息状态标为error但要保留已经生成的文本甚至可以考虑加一条“已停止生成”的系统提示后者是网络故障可以给出重试按钮。重试的常见做法是重新POST一次完整的历史记录但这里有个性能点如果消息已经生成了600字重试就浪费了600字的token。我一般会把已生成的部分和用户原始输入合并成新的history让后端在已有基础上继续而不是从头生成。流式接口的HTTP状态码也需要分别处理。401/403跳登录429做节流提示500及以上提示服务端异常。聊天页面不像普通表单可以弹一个red toast了事用户可能已经等了几秒钟给一条“请求失败点击重试”的横幅比全局toast更友好。4. 会话管理localStorage持久化、多会话切换与隐私清理4.1 会话数据结构索引与消息体分离AI聊天页做到能聊只是第一步用户关掉页面再打开聊天记录还在才是一个可以日常使用的工具。本地存储我选择localStorage因为它同步、零依赖、打开页面就能读不需要和服务端同步。但localStorage有5MB容量限制不能把所有历史消息不加区分地塞进去。我采用索引与消息体分离的结构会话列表存索引每个会话的消息体独立存储。// src/composables/useSessions.js const KEY chat_sessions_v1 const MSG_KEY chat_msgs_ const MAX_SESSIONS 30 const MAX_MESSAGES 60 export function useSessions() { const sessions ref(loadIndex()) function loadIndex() { try { const raw localStorage.getItem(KEY) return raw ? JSON.parse(raw) : [] } catch { return [] } } function saveIndex() { localStorage.setItem(KEY, JSON.stringify(sessions.value)) } function loadMessages(sessionId) { try { const raw localStorage.getItem(MSG_KEY sessionId) return raw ? JSON.parse(raw) : [] } catch { return [] } } function saveMessages(sessionId, messages) { const list messages.slice(-MAX_MESSAGES) localStorage.setItem(MSG_KEY sessionId, JSON.stringify(list)) } function removeSession(sessionId) { sessions.value sessions.value.filter(s s.id ! sessionId) localStorage.removeItem(MSG_KEY sessionId) saveIndex() } return { sessions, loadMessages, saveMessages, removeSession } }loadIndex失败时返回空数组不能用undefined否则后续.filter会直接抛错。MAX_SESSIONS设为30是为了避免localStorage里堆出几十个会话每个会话都有独立key手动清理会想哭。MAX_MESSAGES设60是取一个平衡点存储体量控制在几十KB以内同时保证用户回到旧会话时能看到足够长的上下文。saveMessages里对消息数量做了截断但截断后第一条消息可能是assistant消息没有user提问做引子。我通常会在截断后检查第一条消息的role如果是assistant就补一条system占位这样渲染上不会出现来路不明的AI回复。4.2 持久化时机流式过程中不要碰localStorage持久化时机是最容易犯错的地方。刚写完saveMessages时很容易在onDelta里顺手调用一下让每条增量都写进localStorage。后果是一次生成1000字每10个token触发一次写入就是几十次JSON.stringify和setItem主线程被反复卡顿打字机效果肉眼可见地掉帧。我建议只在三个时机做保存用户发送消息后保存一次把user消息落库、流式结束后保存一次包含完整assistant回复、切换会话时保存一次防止当前会话内容丢失。流式过程里状态完全留在内存中由useChatPanel持有持久化只是内存状态的快照。还有一点localStorage.setItem本身是同步阻塞操作虽然单次只有几十KB但在低端安卓机上也可能卡几十毫秒。如果页面正在滚动这种卡顿会被放大。可以用requestIdleCallback延迟保存把写入推到浏览器空闲时段但要注意页面可能很快被关闭关闭时的beforeunload事件里兜底保存一次。4.3 会话切换与恢复当前会话指针多会话切换需要记录”当前正在看哪个会话“。这个状态不适合放localStorage因为恢复时页面要立刻知道展示哪个会话。我习惯把currentSessionId放在sessionStorage里比内存持久又比localStorage干净——浏览器关闭后自动清除用户重新打开页面看到的是新的空白会话不会被上次的位置干扰。切换会话时先用saveMessages保存当前会话再把loadMessages读出新会话的消息。注意切换动作本身要节流用户快速点击会话列表时连续读取同一个key会触发多余的JSON.parse这里用一个简单的Map做消息缓存命中缓存就直接返回。const msgCache new Map() function loadMessages(sessionId) { if (msgCache.has(sessionId)) return msgCache.get(sessionId) const msgs readFromStorage(sessionId) msgCache.set(sessionId, msgs) return msgs } function saveMessages(sessionId, messages) { msgCache.set(sessionId, messages) scheduleWrite(sessionId, messages) }写入时更新缓存并安排异步写盘读取时优先命中缓存双管齐下即可。缓存导致的内存增长不用过于担心每个会话的消息本来就限制在60条以内。4.4 隐私清理导出、删除与过期策略AI聊天页面最容易忽略的是隐私需求。用户问的可能是公司内部代码、项目排期甚至是一些不适合留在浏览器里的内容。测试时发现某位同事直接把整个生产环境的错误日志粘了进去然后关掉页面就去开会浏览器里明文存着这些数据。后来我给会话列表加了一个“清除全部”的二次确认并且把每个会话的独立删除做成图标按钮而不是藏在菜单里。删除动作要同步清理索引和消息体否则会出现一个残留的空会话。我会在removeSession里同时调saveIndex并用localStorage.removeItem把对应消息key删掉。导出功能可以做成一个四行的JSON文件下载把当前会话的messages和sessions一起打包通过Blob下载。这样做既满足用户备份需求又避免引导用户使用打印页面这种绕路方案。5. AI聊天避坑记输入、渲染、跨域与移动端五个坑5.1 输入区三连坑中文输入法回车、防抖时机和发送后焦点第一个坑来自中文输入法。用户用拼音打完一句话按下Enter键准备选词上屏结果聊天页面直接把还没确认的拼音字符串发送了出去。原因是keydown事件里只判断了event.key Enter没有判断输入法组合状态。解决方式是同时判断event.isComposing和keyCode 229。isComposing为true时表示处于输入法组合中这个回车只是选词操作不能触发发送keyCode 229是旧浏览器判断输入法的备选方案兼容性兜底。两个条件都过了才允许发送。实际写法是if (event.key Enter !event.isComposing)把组合态直接挡在门外。第二个坑是发送按钮的防抖时机。有人习惯给send函数加一个500ms的防抖结果用户在快速连续提问时第二个问题被吞掉。聊天输入的防抖只需要作用于“自动滚动”和“字数统计”发送动作本身不能防抖应该由disabled状态控制sending为true时禁用发送按钮该重试重试该停止停止。第三个坑是发送后焦点丢失。Android软键盘在发送后会收起此时输入框的焦点被移除用户想继续追问时发现输入框没有光标。解决方式是在send完成后主动调用inputRef.value?.focus({ preventScroll: true })preventScroll参数避免页面自动滚到输入框导致阅读位置跳变。5.2 长对话卡顿虚拟滚动什么时候上、怎么上聊天消息到200条时v-for渲染的DOM节点数大约500到800个配合流式更新低端机开始出现明显卡顿。此时先别急着上虚拟滚动因为虚拟滚动在聊天场景里有一个很麻烦的问题滚轮高度是动态的消息高度不固定代码块和超长段落会让滚动条计算崩溃。更便宜的方案是CSScontent-visibility: auto。给每条消息加这个属性浏览器会跳过屏幕外元素的渲染工作渲染成本从O(n)降为O(可见数量)仅一行CSS就能解决大部分卡顿。.msg { content-visibility: auto; contain-intrinsic-size: auto 80px; }contain-intrinsic-size的作用是给未渲染元素一个占位高度避免滚动条跳动。80px是普通消息的行高估算值代码块消息虽然真实高度更高但滚动条跳动幅度在可接受范围内。到消息超过1000条时再考虑虚拟滚动此时需要引入虚拟列表库并且固定每条消息的最大高度或者实测每条消息的真实高度后建立高度映射。虚拟滚动和内容高度不稳定的斗争是聊天组件的一个长期难点留给需要海量消息的页面去处理。遇到流式生成时content-visibility还会带来另一个好处正在滚动的区域外的消息不会被重新布局打字机效果会明显更流畅。这也解释了为什么在过度渲染排查中这条CSS往往是改动成本最低、收益最明显的一行。5.3 聊天接口跨域开发代理与生产部署的代理差异AI聊天接口一般不在同源路径下。开发环境用Vite的proxy解决跨域生产环境如果前端静态文件和接口在不同域名同样需要一个反向代理层。很多人只在开发环境配了proxy生产部署时直接把前端文件丢到CDN接口请求裸奔到另一个域名然后被CORS拦截。本地Vite配置方式是在vite.config.js里加server.proxy把/api/chat转发到后端服务地址。生产环境则要在Nginx或网关层配一层代理。如果两者都没有前端代码里用的/api/chat这种相对路径就会在当前域名下请求必然404或跨域。// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: https://ai-backend.example.com, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })changeOrigin必须设为true否则后端收到的请求Host头是前端域名如果后端有域名校验就挂了。rewrite是把/api前缀摘掉实际后端路由可能是/chat这一步视后端路由设计而定。不要在前端代码里写死http://localhost:xxxx之类的地址用相对路径配合代理换环境时只需调整代理配置。5.4 移动端软键盘顶起、安全区与自动滚动移动端H5聊天页面的问题集中在软键盘上。键盘弹起时window.innerHeight会变化视觉视口被压缩输入框被顶上去。聊天窗口如果设置了固定高度100vh键盘弹起后底部会超出可视区域输入框被键盘完全盖住。推荐使用100dvh单位替代100vhdvh会跟随动态视口变化键盘弹起时聊天容器自动收缩到可视范围。还要配合window.visualViewport.resize事件手动调整底部偏移两者结合才能覆盖不同浏览器的行为差异。自动滚动的坑更隐蔽。流式输出时每收到一个delta就执行一次scrollToBottom()如果用户正在往上翻看历史消息页面会强行把滚动条拉到底部用户体验极为恶劣。常见的做法是记录用户是否在底部附近只有滚动位置距离底部小于240像素时才自动滚动否则只更新消息内容不改变滚动位置。240像素是根据用户手指位置的典型视觉感知范围定的经验值调试时可以直接调这个数值。6. 性能验证把聊天页渲染长任务压到100ms以内流式聊天页面验证的重点不是功能跑通而是主线程在生成过程中的卡顿长任务有多少。打开浏览器的Performance面板录制一次完整的生成过程观察主线程的火焰图。如果出现超过100ms的同步任务用户就会感知到打字机停顿这个停顿会直接拉低整个AI聊天的体验评价。我在排查中发现最频繁的长任务来源是每收到一个delta就同步更新响应式状态。Vue 3的响应式系统足够快但如果一个同步任务里把几十次delta并入一次更新效果会更好。做法是加一个rAF合并层把同一帧内的多个delta合并到一次写入。// src/composables/useBatchedDelta.js let rafId null let batch const flush () { appendContent(batch) batch rafId null } export function appendDelta(txt) { batch txt if (rafId ! null) return rafId requestAnimationFrame(flush) }这段代码的逻辑是把连续多次appendDelta合并成一个rAF回调执行。比如后端一瞬间推来200条增量原本要触发200次响应性更新现在只触发一次。注意flush执行后要把rafId置空否则下一次appendDelta不会重新注册rAF。如果流式结束时还有挂起的rAF没有执行需要在onDone里手动cancelAnimationFrame并调用一次flush保证最后一段文本不丢失。验证效率时我习惯用Vue DevTools的组件高亮功能把状态更新时间可视化切到“每秒更新次数”视图观察聊天组件的更新频率。理想状态下流式过程每秒的更新次数大约在10到20次区间这是人类感知流畅的最低刷新率。如果看到每秒更新上百次说明批次合并没有生效优先检查rAF调度是否被频繁cancel。另一个容易被忽视的验证点是消息列表的key稳定性。ChatMessage组件需要一个稳定且唯一的keyid字段是最优选择。如果用数组下标当key流式追加时新消息插入列表中间会造成后续所有消息被重新渲染几百条消息的DOM树瞬间重建代价极高。用稳定id时Vue只知道新增了一条消息旧消息全部复用更新成本被压到新增节点一条。从那以后我每次写完聊天页面都会强制检查一圈key是否稳定、SSE是否有[中断]处理、输入法组合态是否拦截、localStorage写入是否超频这四条过一遍基本不会在线上翻车。希望帮到你。注意流式接口返回的数据格式不是一个标准规范不同后端的SSE字段结构可能完全不同。接入前先拿一条真实返回抓包确认字段名不要默认一定是choices[0].delta.content把解析逻辑单独抽成函数后端改了只改一个地方。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑