Vue3 + MateChat 打造流式AI聊天界面:SSE与组合式API实战
简介面向 Vue3 初学者的 AI 智能聊天演示项目采用主流前端技术组合Vue3、Element Plus、Sass、TypeScript、Pinia覆盖组件化开发、状态管理与工程化组织等核心知识点也可作为对接私域应用时快速生成聊天页面的基础模板。资源包共84个文件以类型脚本代码、视图组件和逻辑脚本为主同时提供样式表文件、项目配置、三维模型、解码依赖、页面入口和说明文档压缩后仅 6.39MB结构紧凑便于本地运行与调试。项目内置登录、首页、404与500错误页等多个页面场景并将路由切换、状态管理、接口请求、国际化等模块做了清晰划分引入 MateChat 后可以实现移动端自适应的人工智能对话交互示范了从搭建聊天界面到接入对话能力的完整过程。对于想要梳理 Vue3 配合 TypeScript 项目结构、理解环境变量与打包配置的同学源码目录和构建产物都有助于按图索骥已有164人学习浏览适合作为中初级开发者的练习与参考。 把vue3和MateChat组合在一起做AI聊天这事儿比我预想的要顺不少但中间也有不少环节值得好好捋一捋。这个项目最终做出来的是一个带流式输出、能直接接入大模型接口的聊天界面Demo消息逐字往外蹦、卡片状态实时更新、输入框交互贴合主流聊天软件的操作习惯。适合正在学vue3想搞个能拿得出手的练手项目的人也适合公司里要快速搭建AI客服、知识库问答原型的前端同学甚至可以当作研究大模型接口对接思路的入门样本。在动手之前我认真想了一下整个前端架子用vue3扛聊天UI这些交互细节直接用MateChat不自己从零手搓气泡、输入框、滚动逻辑后端对接的AI能力就走常规的大模型HTTP接口用SSE把token流式推回来。这样做的好处是UI层和接口层各干各的代码结构清楚后面换模型、换接口都动不到界面。1. 项目思路与整体方案设计1.1 为什么拿vue3做底座vue3这版对比vue2最大的变化就是组合式API。以前写一个聊天组件data、methods、computed、watch各放一摊跟聊天逻辑相关的东西被拆得七零八落现在用setup把消息列表、发送动作、流式更新、错误重试这些逻辑按功能收进一个useChat函数里数据流向一眼就能看明白。另外vue3的响应式系统改成Proxy实现数组下标修改、动态新增属性这类操作都能被代理捕获对聊天这种高频push新消息、更新消息状态比如把生成中变成已完成的场景来说特别合适。然后TypeScript支持也更顺手消息结构、接口返回类型、流式回调参数都能定义成interface写的时候能少很多低级错误。在构建工具上我这里用的是Vite。新项目直接npm create vite创建就能拿到模板开发热更新速度极快几乎不用等编译改完代码浏览器立刻刷新。这对调试聊天类的交互界面尤其重要——你要频繁改输入框状态、滚动判定、气泡样式稍有修改都要迅速看到效果。1.2 MateChat在项目里到底承担什么MateChat可以理解成一个为LLM聊天场景定制的高阶UI组件库针对多轮对话流式输出Markdown渲染这些需求做了现成的封装。项目里用它的核心目的就是不想在气泡布局、头像间距、自动滚动这些通用交互上浪费时间把精力集中在业务逻辑上。在实际开发中我要确认它的组件接口和插槽设计一般有两种方式文档写得齐的按文档来文档不全的就直接翻源码看props和slots。根据我做的这类组件解析它通常会暴露messages数组、发送事件、loading状态这几个核心接口外部往里面传消息数据监听send事件拿用户问题再控制loading状态展示正在生成的等待效果。如果你手头用的组件API略有差异按同样的思路映射过去就行核心还是把消息数据流打通。除了这些基础接口MateChat还会开放一些自定义插槽比如消息渲染插槽、输入框插槽便于业务方接管特定区域。我的做法是不动源码全部通过外部封装来适配这样后续升级组件版本不用重新移植业务代码。1.3 项目的整体目录与功能边界我搭出来的Demo目录结构大致是这样src/ components/ ChatContainer.vue // 外层容器挂载MateChat ChatInput.vue // 输入区基于插槽或内置区扩展 MessageList.vue // 消息列表结构定制 composables/ useChat.js // 核心聊天逻辑消息管理、发送、流式处理 useSSE.js // 封装fetch ReadableStream读取 api/ chat.js // 请求地址、参数组装 types/ chat.js // 消息对象结构定义功能边界控制在这几块用户输入问题点击发送或回车触发请求后端以SSE方式逐段返回内容前端实时渲染消息按用户/助手角色区分展示状态分为加载中、正常、错误支持停止当前生成、清空会话对话过程中自动滚动到底部同时保留用户上翻查看历史的自由MVP阶段不做的多会话历史管理、登录鉴权、语音输入输出。这些等基础链路跑顺了再扩展不然头一个版本就容易被杂事拖住。2. 核心细节解析与实操要点2.1 数据结构的提前约定聊天应用的数据流能不能走顺很大程度取决于消息结构设计得靠不靠谱。我在types/chat.js里定义了一套基础结构后面所有组件都围绕它来操作。export function createUserMessage(content) { return { id: Date.now().toString(36) Math.random().toString(36).slice(2, 8), role: user, content, status: done, createTime: Date.now() } } export function createAssistantMessage() { return { id: Date.now().toString(36) Math.random().toString(36).slice(2, 8), role: assistant, content: , status: streaming, createTime: Date.now() } }id不喜欢用自增数字因为消息可能涉及插入、删除、重新排序数字id在并发场景下容易乱用随机字符串更稳。status字段标记消息的展示状态streaming表示AI正在输出done表示正常结束error表示中途出错组件就能根据status动态决定气泡样式和是否展示重试按钮。这个结构最大的好处是不管接口返回什么复杂结构我在前端最终都映射成统一的content字段渲染层不用关心数据源细节。2.2 输入框的交互细节处理聊天输入框最烦人的问题就是中文输入法回车误发送。我说的不是英文输入直接按回车而是用拼音输入法选字后习惯性按回车确认结果消息“咣”一下就发出去了。这个问题不处理使用体验直接打对折。处理方案是在监听keydown的时候检查event.isComposing。输入法组合状态未结束时键盘事件会带isComposing: true这时候直接return不触发发送逻辑。同时还要监听compositionstart和compositionend两个事件确保组合状态标记正确。template div classchat-input textarea v-modeldraft keydownhandleKeydown compositionstartisComposing true compositionendisComposing false placeholder输入消息Enter发送ShiftEnter换行 /textarea /div /template script setup import { ref } from vue const draft ref() const isComposing ref(false) function handleKeydown(e) { if (e.key Enter !e.shiftKey !isComposing.value) { e.preventDefault() sendMessage() } } function sendMessage() { const text draft.value.trim() if (!text) return // 这里的发送逻辑交给外层的useChat emit(send, text) draft.value } /script还有个容易被忽略的点发送按钮需要同步禁用。当draft为空或者消息正在生成时按钮应该置灰否则用户连点会发出多条请求。2.3 与MateChat组件的对接方式接MateChat的时候不一定它的所有行为都符合项目需要所以我习惯在外面包一层业务组件把交互逻辑和UI组件解耦。比如ChatContainer.vue里组件接收到send事件后不再自己处理而是emit给父级业务页面由useChat负责发请求和更新消息。这种方式的好处是将来要替换MateChat或者改用其他UI库只需要改ChatContainer内部的模板实现消息状态管理逻辑完全不用动。这也是vue3里典型的“UI与业务分离”思路。聊天消息气泡的渲染我用作用域插槽定制消息内容区。如果MateChat提供默认的消息渲染我会在需要的地方覆盖成自己封装的MessageItem组件这样方便控制Markdown渲染、代码高亮、错误重试按钮这些能力。3. 实操过程与核心环节实现3.1 消息状态管理与useChat封装消息列表是整个应用的心脏。直接用ref([])当然能跑但把增删改查、生成状态判定、错误处理都塞在组件里很快就失控了。我选择用composable来管理。// composables/useChat.js import { ref } from vue export function useChat() { const messages ref([]) const loading ref(false) function addMessage(msg) { messages.value.push(msg) } function updateMessage(id, patch) { const target messages.value.find(m m.id id) if (target) { Object.assign(target, patch) } } async function sendMessage(text) { if (loading.value) return const userMsg createUserMessage(text) addMessage(userMsg) loading.value true const assistantMsg createAssistantMessage() addMessage(assistantMsg) try { await streamChat({ messages: messages.value.map(m ({ role: m.role, content: m.content })), onMessage: (delta) { assistantMsg.content delta }, onDone: () { updateMessage(assistantMsg.id, { status: done }) }, onError: (err) { updateMessage(assistantMsg.id, { status: error, content: assistantMsg.content || 请求出错了 err.message }) } }) } finally { loading.value false } } function clearMessages() { messages.value [] } return { messages, loading, sendMessage, clearMessages } }这里的onMessage回调每次拿到一个delta就往后拼接内容。内容一变messages数组里对应的对象也会跟着变界面自动更新。刚开始写的时候我想过是否每次delta都触发重渲染会太频繁实测发现多数大模型接口返回的每个chunk就十几个字符vue3的响应式更新扛得住不需要做额外节流。3.2 SSE流式输出的前端实现大模型接口返回说到底是SSE流常见做法两种EventSource和fetch ReadableStream。EventSource看起来简单但它不支持自定义请求头而我们调用线上接口基本都要在header里带Authorization token所以直接pass。用fetch来拿流更可控。核心原理fetch发起请求后后端会让连接保持打开持续把数据分片推送过来。浏览器这边拿到response.body它是一个ReadableStream我们可以用reader.read()循环读取每次读到的chunk用TextDecoder解码然后逐行解析。// composables/useSSE.js export async function streamChat({ messages, onMessage, onDone, onError }) { const controller new AbortController() try { const response await fetch(https://api.example.com/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer localStorage.getItem(token) }, body: JSON.stringify({ model: gpt-3.5-turbo, stream: true, messages }), signal: controller.signal }) if (!response.ok) { throw new Error(HTTP response.status) } const reader response.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { value, done } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) const lines buffer.split(\n) buffer lines.pop() || for (const line of lines) { const trimmed line.trim() if (!trimmed.startsWith(data:)) continue const data trimmed.replace(/^data:\s*/, ) if (data [DONE]) { onDone() return } try { const json JSON.parse(data) const delta json.choices?.[0]?.delta?.content || if (delta) onMessage(delta) } catch (e) { // 忽略JSON解析失败的行继续等待完整数据 } } } } catch (err) { if (err.name AbortError) { // 主动取消时不算错误 onDone() } else { onError(err) } } }注意解码这里TextDecoder一定加{ stream: true }否则一个完整的中文字符被拆在两个chunk里时第二次decode会把前半个字符解析乱。这个坑我踩过打印出来的内容末尾会偶尔出现“”。还有一个细节是buffer处理。SSE数据是按\n分隔的但网络传输不一定正好按行切分所以要把没拼完整的partial行留到下一轮拼接等接到了完整的\n再做解析。代码里的buffer lines.pop() || 干的就是这个活。3.3 停止生成是怎么实现的用户点“停止生成”按钮本质上就是终止浏览器和服务器之间的连接。最直接的办法是用AbortController。在useChat里保持一个abortController引用sendMessage时创建stopGeneration时调用controller.abort()上面的streamChat函数会收到AbortError我们在catch里判断到err.name AbortError就走onDone逻辑把消息标记为已完成。实际对接的时候需要注意有些接口支持客户端主动中断服务端也会停止计算有些接口不行前端断开了后端还会继续推理完。前端至少能做到停止渲染体验上也是有效的。4. 常见问题与排查技巧实录4.1 中文输入法的回车误发问题这个在上面2.2里已经给了核心解法检查isComposing。但我发现很多初学者会漏掉一个细节——compositionend触发的时机。有时候用户在拼音候选框里点候选词compositionend会在keydown之前触发这时候如果代码里对keydown的判断不够严谨回车还是会漏出去。稳妥起见可以加一个时间戳标记在compositionend后的一小段时间窗口内忽略enter发送。4.2 流式输出时界面卡顿当接口返回速度很快比如每秒吐上百个tokenonMessage高频调用会频繁触发组件更新。排查时可以通过performance面板看看是不是渲染占满了主线程。解法是在useChat和渲染层之间加缓冲区用requestAnimationFrame来合并同帧内的多次更新。在onMessage时只做字符串拼接然后用rAF标记下一帧再同步渲染这样一个帧周期内无论来了多少chunk界面最多更新一次。let rafId null function scheduleRender() { if (rafId) return rafId requestAnimationFrame(() { renderContent.value draftContent rafId null }) }4.3 页面自动滚动和用户手动滚动打架聊天气泡一多自动滚到底部的逻辑就很重要。但如果在用户上翻查看早先消息期间流式输出还在继续自动滚动会粗暴地把用户扯回底部体验很糟糕。解法是监听滚动事件判断当前是否在底部附近。如果用户距离底部超过一定阈值比如120px就暂停自动滚动等用户主动滚回底部附近再恢复。function isNearBottom() { const el scrollContainer.value return el.scrollHeight - el.scrollTop - el.clientHeight 120 }4.4 消息内容出现代码块、Markdown无法渲染默认消息是纯文本渲染但AI回答里几乎必然出现Markdown像代码块、表格、加粗。接上来都是原样字符串用户看着很费劲。我引入了一个轻量的Markdown解析依赖对内容做解析后在MessageItem里渲染。这一步要注意XSS问题AI生成的内容里如果带script标签直接用v-html渲染是有风险的需要过滤或依赖库自带的安全机制。在demo阶段我用了一个较为保守的策略只渲染白名单标签其他一律转义。4.5 请求报跨域CORS错误本地开发时前端页面跑在5173端口后端接口在另外的地址大概率碰到CORS。最简单的方式不是让后端开CORS而是用Vite的devServer代理前端请求/api代理插件把它转发到真实接口地址。// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: https://your-api-host.com, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } })遇到CORS报错时先不慌优先配置代理这样生产环境部署也会更灵活。4.6 组件销毁后请求回调导致的内存泄漏用户发完消息立刻切换页面或关闭组件请求还在飞回调函数却还在操作已经卸载的组件控制台会出现警告甚至可能报错。在useChat的onBeforeUnmount里主动abort掉未完成的请求并且把回调函数里对组件状态的操作放在一个“组件是否已卸载”的标志位后面保护起来。5. 开发过程中的安全与健壮性细节AI聊天看起来功能简单但要认真用起来安全和健壮性要考虑不少。说几个我在实测里比较在意的点。5.1 接口鉴权和密钥保护调用大模型接口需要API Key这个东西绝对不应该写在前端代码里。有人图省事直接写在vite的.env文件里但构建后的js bundle一抓就出来了等于公开暴露资产。正确姿势是经过一个后端转发层前端把问候语发给自己的服务端由服务端保管密钥、组装请求再把SSE流转回前端。这个demo阶段为了简化直接用了一段本地mock接口但正式项目一定有这一层。5.2 错误提示的友好度请求超时、HTTP 429限流、网络断开不同错误给用户的提示应该不同。我的做法是把后端返回的错误码映射成前端可以展示的文案超时就提示“长时间未响应请重试”限流就提示“请求过于频繁请稍后”太笼统的报错还要附上错误码方便开发联调。5.3 输入内容的边界处理用户发一段空字符串、发纯空格、发超长文本前端都要做约束。纯空格直接拦截超长文本用maxlength卡一下。另一个容易忽略的是用户的输入里包含特殊字符比如HTML标签发到后端再经模型返回拼接在页面上一不小心变成XSS向量。发送前对输入文本做转义是省心又安全的习惯。6. 实际运行效果与后续扩展方向整个Demo跑通之后我在本地连续做了几轮测试。输入“帮我写一段防抖函数”AI的回答以代码块形式流式渲染出来代码高亮生效输入框在中文输入状态下没有误发消息停止生成按钮能立马终止打印。流式场景下大约40多个chunk的响应界面更新没有卡顿感。整体体验已经很接近市面上商用的AI对话产品了。这个项目再往下走有几个很实际的方向。一是接入会话记录管理把多轮对话存到本地或后端刷新页面对话不丢失二是在输入框里支持附件或图片配合多模态模型使用三是把消息流抽取成可复用的SDK多页面引用。另外如果要把聊天UI做得更深入MateChat这类组件的自定义样式、暗黑模式适配、移动端适配都值得折腾一遍。最后再分享一个我个人的实操体会做这类AI聊天项目八成精力其实在细节上中文输入法、滚动冲突、流式解析的坑、密钥保护这些在任何AI产品里都是躲不开的通用问题。把这类工程细节记录成笔记比单纯跑通接口有意义得多。本文还有配套的精品资源点击获取