Vue 3实现AI聊天页面:流式输出与增量渲染实战指南

📅 发布时间:2026/10/17 15:33:02
Vue 3实现AI聊天页面:流式输出与增量渲染实战指南
简介这是一份面向 Vue 开发者和 AI 应用初学者的完整前端项目基于 Vue 3 生态实现 AI 聊天交互界面可用来学习组件化页面搭建、状态管理及异步消息处理。压缩包约 668KB共 79 个文件包含 3 个 Vue 组件、5 个 JavaScript 文件、3 个 JSON 配置以及 HTML、PNG、Markdown 等辅助文件基本覆盖从工程配置到页面展示的常见结构。项目内还保留了 Git 仓库元数据和钩子脚本便于了解实际开发中的版本管理与提交规范。目前已有 1110 人学习浏览适合希望快速上手 Vue 3 项目结构、参考 AI 对话界面实现方式或在此基础上扩展机器人客服、智能助手等功能的开发者。整体目录划分清晰源码、公共资源与说明文档分层存放拿到后可按需阅读 README 并直接运行调试节省从零搭建的时间。1. 基于Vue 3的AI聊天页面不止是套一个输入框如果你在 2024 年之后接过任何一个「做个 AI 聊天页面」的需求会发现它和传统客服聊天窗口完全是两码事。传统聊天页面的核心是消息的存储与展示而 AI 聊天页面的核心是流式输出用户发出提问服务端不是一次性返回完整回答而是像人打字一样逐字吐出内容。这个差异决定了页面架构的走向——从消息数据结构设计、渲染链路选择到滚动策略、中断处理每一步都在围绕「流」来转。基于 Vue 3 做这件事组合一套响应式状态管理加流式 fetch 方案才能既保证开发效率又避免「答一句卡三秒」的劣质体验。这篇文章直接按我的落地路径走先拆模块再写最小页面接流式接口最后把我在真实项目里踩过的坑一一列给你。2. 拆清需求再动手聊天页面的模块划分与关键技术选型聊页面之前先把模块边界画清楚。一个能上线使用的 AI 聊天页面通常包含四个模块消息列表区、输入区、会话上下文管理、以及网络层。消息列表区负责展示历史消息和当前 AI 回复输入区负责采集用户输入、处理快捷键和发送状态会话上下文管理维护整个对话的消息数组并负责把 messages 传给接口做多轮接续网络层则专门处理流式请求的建立、读取、中断和异常。四个模块中消息列表和网络层的耦合最深也最容易出问题。很多从普通后台管理转过来的开发者习惯用「接口返回数据后一次性 set 状态」的思维写聊天页面对流式输出时就像用桶接水却不知道水是滴着来的。所以选型的第一原则就是数据流必须设计成增量更新而不是整段替换。2.1 消息流是核心消息数据结构与状态管理方式消息结构决定了后续所有逻辑的复杂程度。我建议在页面里统一使用扁平结构而非嵌套结构因为嵌套会让滚动定位、消息索引、虚拟列表的 key 设置都变得繁琐。每一条消息至少包含四个字段interface ChatMessage { id: string; // 唯一ID用 uuid 或者时间戳随机数 role: user | assistant; content: string; streaming: boolean; // 标记当前消息是否正在流式接收 error?: string; // 可选记录错误信息 }id字段是必须的它既是 Vue 列表渲染的 key也是流式更新时精确定位的索引。streaming字段建议每个开发者都加——它让组件能区分「这条消息还在打字」和「这条消息已完整到达」从而控制光标闪烁、停止按钮的显隐、以及滚动的自动吸附行为。状态管理方面我建议直接用 Vue 3 的reactive或者ref维护一个消息数组不去套 Pinia。原因很简单消息数组的状态只属于当前页面没有跨页面共享需求。引入 Pinia 意味着多一层抽象、多一份样板代码收益却几乎为零。如果哪天要做多会话切换再把它提升到 Pinia 里也不迟迁移成本并不高。2.2 渲染链路选型Markdown、代码高亮与流式更新的取舍AI 返回的内容几乎必然是 Markdown 格式所以渲染链路选型本质上是在回答三个问题用什么把 Markdown 转成 HTML、用什么做代码高亮、以及流式更新时这套链路能不能扛住高频刷新。Markdown 渲染我一般选marked或markdown-it。marked的包体积更小、API 更简单适合不需要复杂插件的场景markdown-it的插件生态更完整如果后续要给表格加排序、给标题加锚点扩展性更好。两个库的核心差异不在渲染结果而在扩展成本。代码高亮用highlight.js就够了语法种类覆盖广、主题多。需要特别注意的是流式输出的代码块是逐步拼出来的AI 先输出python再逐行输出代码体最后才收尾。如果不做处理中间每一个字符都会触发一次整块代码的高亮计算——这在高亮大段代码时会产生肉眼可见的卡顿。常见的做法是给高亮计算加防抖或者只在streaming false时才做完整高亮写作过程中先渲染纯文本代码块。2.3 长对话性能虚拟滚动与消息分片的边界聊到长对话必须面对两个现实浏览器 DOM 节点有性能上限AI 单条回复可以达到上千字甚至几千字。如果每条回答都是一个包含代码块、表格、图片的复杂 DOM 结构页面在对话超过 20 轮时就开始明显掉帧。虚拟滚动是最终的解法但我不建议一开始就上。vue-virtual-scroller这类库的接入会改变滚动容器的结构对自动滚动到底部的逻辑、对「按住往上翻」的交互都会有侵入。我的经验是先写好常规滚动等消息量真的到了 30 轮以上再改造虚拟滚动。前期用「仅渲染最近 N 条消息 占位符」的简单分片策略就足够支撑 demo 和内部测试。分片策略指的是在 messages 数组上做计算属性截断不渲染全部 DOM而不是真的去处理分页加载。3. 把骨架搭起来消息区、输入区与最小可用页面选型定完直接写代码。这一章的目标是搭出一个「能聊起来」的最小页面左边是消息滚动区下面是输入框按 Enter 发送、Shift Enter 换行。先不接流式接口用一个模拟返回把整条链路走通再接真接口时只替换网络层页面的其他部分不用动。3.1 最小页面骨架消息列表 输入框 发送按钮我习惯用组合式 API 的写法组织这个页面因为聊天页的状态逻辑天然适合用setup集中管理。下面这个组件是页面骨架的完整代码只保留最核心的功能template div classchat-page div refmessageListRef classmessage-list div v-formsg in messages :keymsg.id classmessage-item div classmessage-role{{ msg.role user ? 我 : AI }}/div div classmessage-content{{ msg.content }}/div /div /div div classinput-area textarea v-modeldraft placeholder请输入你的问题Enter 发送Shift Enter 换行 keydownhandleKeydown /textarea button :disabledsending clicksendMessage发送/button /div /div /template script setup langts import { ref, nextTick } from vue interface ChatMessage { id: string role: user | assistant content: string } const messages refChatMessage[]([]) const draft ref() const sending ref(false) const messageListRef refHTMLElement | null(null) function handleKeydown(e: KeyboardEvent) { // 只在按下 Enter 且没有按 Shift 时发送避免误伤换行场景 if (e.key Enter !e.shiftKey) { e.preventDefault() sendMessage() } } async function sendMessage() { const content draft.value.trim() if (!content || sending.value) return draft.value sending.value true // 先把用户消息插入列表再做接口请求这是聊天界面的基本节奏 messages.value.push({ id: Date.now().toString(), role: user, content, }) // 模拟 AI 回复实际项目中这一块会被流式读取替换 const reply: ChatMessage { id: (Date.now() 1).toString(), role: assistant, content: , } messages.value.push(reply) await new Promise((r) setTimeout(r, 300)) const fakeText 这是一段模拟回复。接入真实接口后这段文字会换成逐字到来的流式内容。 reply.content fakeText scrollToBottom() sending.value false } async function scrollToBottom() { await nextTick() if (messageListRef.value) { messageListRef.value.scrollTop messageListRef.value.scrollHeight } } /script这里有两个细节值得注意。第一是keydown写在了textarea上而不是全局键盘监听上这样不会抢走页面上其他输入框的焦点事件。第二是handleKeydown里的判断顺序——先判定key Enter再判定shiftKey顺序反了会导致「Shift Enter 时也触发了发送」这种经典 bug。sendMessage的流程是标准的三步清空输入框、把用户消息入列、再创建 assistant 空消息等待填充。先把 assistant 的空消息入列这一点很重要后面接流式时它就是你增量更新内容的容器。3.2 消息渲染组件把普通文本和 Markdown 统一收口骨架里的消息内容直接用{{ msg.content }}输出这在模拟阶段没问题但接真实接口后 AI 返回的是 Markdown必须换成专门的渲染组件。我的做法是抽象一个MessageContent子组件组件内部根据内容格式决定走文本渲染还是 Markdown 渲染未来如果要做代码块复制按钮、思考过程折叠都以这个组件为入口扩展。template div classmessage-content !-- 简单判断包含 Markdown 标记符就走渲染否则保持纯文本 -- div v-ifisMarkdown v-htmlrenderedHtml/div div v-else classplain-text{{ content }}/div /div /template script setup langts import { computed } from vue import { marked } from marked import hljs from highlight.js const props defineProps{ content: string streaming: boolean }() const isMarkdown computed(() { // 常见的 Markdown 标记标题/代码块/加粗/列表 return /(#{1,6}\s|\n|[*_]{2})/.test(props.content) }) const renderedHtml computed(() { // 自定义 marked 的代码高亮渲染避免每个字符都去跑高亮 marked.setOptions({ highlight(code: string, lang: string) { if (lang hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value } return hljs.highlightAuto(code).value }, }) return marked(props.content) }) /script注意renderedHtml里的计算逻辑。我把setOptions放在了computed内部这是故意的不同消息可能需要不同的 marked 配置放全局容易互相污染。但这样写也意味着每次 computed 重新计算时都会重设一次 options性能上有轻微浪费。如果你对性能敏感就把这个配置提升到模块顶层只在初始化时设置一次。streamingprop 在这个组件里还没有实际用到后面接流式渲染时会用到当streaming为 true 时我会选择跳过代码高亮只做纯 Markdown 转换减少频繁重绘带来的开销。3.3 发送流程从用户输入到消息入列的完整链路上面的代码把发送流程写在一个函数里但真实项目里我一般会拆成更小的步骤分别处理「校验」「入列」「请求」「回填」四个环节。拆开的核心原因是每个环节的失败处理方式不同校验失败只需要本地提示入列后的用户消息是永久性的请求失败要给重试入口回填失败则要保持 AI 消息的可见性并标记错误。function validateInput(text: string): boolean { const trimmed text.trim() // 空内容不发送这是最基本的约束 if (!trimmed) return false // 超出长度限制需要截断或提示一般服务端也会限制但前端先卡一道 if (trimmed.length 4000) { alert(问题字数超出限制请精简后重试) return false } return true } function appendUserMessage(text: string): void { messages.value.push({ id: crypto.randomUUID(), role: user, content: text, }) } function appendAssistantMessage(): ChatMessage { const msg: ChatMessage { id: crypto.randomUUID(), role: assistant, content: , streaming: true, } messages.value.push(msg) return msg }appendAssistantMessage返回消息对象引用而不是返回 id 让外部再查找这样网络层可以直接对引用做msg.content chunk的增量操作省去了一次数组 find 的遍历开销。高频流式更新中每秒可能触发几十次内容追加少一次查找就少一次性能损耗。这是我对「能用引用就用引用」这条原则的贯彻。4. 让 AI「打字」给你看SSE 流式输出的接入与增量渲染骨架上的模拟回复是整体到达的但真实 AI 服务几乎都支持流式输出通过 HTTP 长连接把回答按 token 逐个推给前端。前端读取流并逐字追加到消息内容中用户就会看到打字机式的输出效果。这一章是聊天页面最核心的部分怎么建连、怎么解析、怎么更新视图。4.1 SSE 与普通 POST 的区别为什么聊天页面必须用流理解 SSEServer-Sent Events和普通 POST 的区别一句话就够普通 POST 是服务端攒完所有内容一次性返回SSE 是服务端生成一点就推一点。大模型生成一段回答可能需要 20 秒普通 POST 在这 20 秒里对用户是黑匣子——不知道在生成、不知道生成到哪、也不知道是不是卡死了。SSE 则天然把生成过程变成了可见事件流每生成一个 token 就通过 HTTP 连接推送给前端。实际开发中我几乎不直接使用EventSource对象而是用fetch配合ReadableStream手动解析。原因有两个一是聊天接口通常需要 POST 方法携带上下文而EventSource只支持 GET二是EventSource的自动重连策略在聊天场景里常常帮倒忙——AI 回答中途断连自动重连会重新发起整个请求用户会看到回答从头再打一遍。4.2 用 fetch 读取流ReadableStream 的解析与断句下面这段代码是在生产项目里验证过的流式读取实现包含了最完整的处理逻辑按行分割、处理data:前缀、识别结束标记、错误处理。注释里标注了每个关键点的设计意图async function fetchChatStream( messages: { role: string; content: string }[], onChunk: (text: string) void, signal: AbortSignal ): Promisevoid { // 从浏览器发起 POST 流式请求头部必须声明 Accept: text/event-stream const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, }, body: JSON.stringify({ messages }), signal, // 把 AbortSignal 传给 fetch中断请求时才能正确取消 }) if (!resp.ok) { // 非 2xx 响应不需要走流解析直接抛错让上层处理 const errBody await resp.text() throw new Error(请求失败: ${resp.status} ${errBody}) } const reader resp.body!.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { // 读取是一段段的不一定按行切分用 buffer 攒数据再做行级解析 const { done, value } 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.slice(5).trim() if (data [DONE]) return // 服务端的终止标记 try { // 注意有的服务端一次 data 里可能带多个 JSON 对象要按需要拆分 const parsed JSON.parse(data) const delta parsed.choices?.[0]?.delta?.content ?? if (delta) onChunk(delta) } catch (e) { console.warn(解析流数据失败:, data, e) } } } }这段代码的设计关键在buffer变量。流式读取的底层是二进制块一个 chunk 里可能包含半行数据也可能包含多行数据直接在每次读取时做行分割一定会漏消息或错位。先把数据追加进buffer然后按\n切行最后把可能不完整的末尾留在buffer里——这个「留尾」的手法实际排查时能省掉大量莫名其妙的丢字问题。4.3 增量渲染的更新策略避免整段重绘拿到流式数据后最直接的更新方式是每收到一个 chunk 就messages.value[index].content delta。这个写法能工作但组件更新时整个聊天列表都会重新渲染消息多了就会掉帧。更好的策略是把「流式接收中的消息」和「稳定的历史消息」分开处理。// 流式接收中的消息放在独立的 ref 里避免触发整个列表的 diff const streamingMessage refChatMessage | null(null) function handleChunk(delta: string) { if (!streamingMessage.value) return streamingMessage.value.content delta scrollToBottom() } function startStreaming() { streamingMessage.value { id: crypto.randomUUID(), role: assistant, content: , streaming: true } messages.value.push(streamingMessage.value) // 外层的列表仍然只显示 role 和 loading 状态 }这里的核心思想是列表组件对streamingMessage的渲染走独立的插槽或子组件不与历史消息的渲染路径混在一起。这样历史的 30 条消息不会参与每一帧的 diffVue 只需要重绘那个正在变化的子组件。实际优化效果非常明显消息越长收益越大。4.4 中断与重试AbortController 的正确用法回答到一半用户点了「停止生成」或者网络抖动导致连接断开这都需要前端能干净利落地中断。AbortController是浏览器提供的中断机制关键是把它的signal同时传给 fetch 和事件处理这样中断才能生效let abortController: AbortController | null null function stopGenerate() { // 如果当前有正在进行的流式请求直接中断 if (abortController) { abortController.abort() abortController null } // 把正在流式输出的消息标记为已结束保留已生成的部分内容 if (streamingMessage.value) { streamingMessage.value.streaming false } } async function sendMessage() { // 发送新消息前先把上一次可能还没结束的请求中断掉 if (abortController) { abortController.abort() } abortController new AbortController() try { const replyMessage appendAssistantMessage() streamingMessage.value replyMessage await fetchChatStream(messagesForApi, (delta) { replyMessage.content delta }, abortController.signal) // 正常结束封口 replyMessage.streaming false } catch (e) { if (abortController.signal.aborted) { // 用户主动中断不做错误提示保持已生成内容可见 replyMessage.streaming false return } // 非主动中断标记错误并提供重试按钮 replyMessage.error (e as Error).message replyMessage.streaming false } finally { abortController null streamingMessage.value null } }几个细节的处理finally里统一清理状态避免残留非主动中断时给消息加error字段由界面显示一个小的重试按钮重试时可以把该消息从数组中移除再重新请求被中断的消息保留已经生成的内容而不是整条删除——用户看了半天的回答因为一次网络抖动就消失是聊天页体验上最严重的流失点。5. 聊天页面常见的5个坑从白屏到重复请求前面的代码解决了「正常路径」的问题但真实开发里几乎一定是在排错中度过更多时间。这一章把我在不同项目里反复遇到的五个问题写全每一条都是实际发生的不是理论推演。5.1 坑1Enter 发送时输入法组词被误提交现象用户在中文输入法下打完拼音正在候选词列表里选词一按 Enter 想确认候选词结果整条消息直接发送出去了。原因keydown监听 Enter 时不区分「输入法组词确认」和「真正的消息发送」。输入法在组词状态下按 Enterkeydown事件里key的值可能是Enter也可能是Process不同浏览器行为不一致但最常见的情况是触发了发送。解决在keydown里检查e.isComposing属性。组合输入期间这个属性为true只有在它不为 true 时才走发送逻辑。更稳妥的写法是同时监听compositionstart和compositionend维护一个isComposing标志因为isComposing属性在部分浏览器版本上有兼容性问题。没有这个判断之前用户每打一段中文都要忍受「打字打得正起劲消息飞出去」的翻车体验。5.2 坑2流式输出时滚动条不听使唤现象用户正在往上翻看之前的回答AI 的流式输出却不停把滚动条拉回底部用户完全没法正常阅读。原因流式更新的每一个 chunk 都触发scrollToBottom把滚动位置强行钉在了底部。用户往上的滚动行为被程序覆盖这是交互设计上的冒犯。解决滚动到底部前先判断「用户当前是否已经远离底部」。判断方法是在滚动容器上监听scroll事件记录当前滚动位置和最大滚动距离的差值只有这个差值小于一个阈值比如 80px时才允许自动吸附到底部。阈值设 50-100px 之间比较合适太小容易在用户刚往上拉一点点时被强行拽回太大会出现用户明明在底部附近却不自动滚动的情况。5.3 坑3组件卸载后 setState 报错现象用户发送消息后立刻切换页面或关闭标签页控制台报「Invalid state: Cannot update a component while rendering a different component」或者类似的更新警告。原因流式请求是异步的组件已经被销毁但reader.read()的 Promise 还在继续执行并把数据写入reactive对象。Vue 在组件卸载后对这些更新既不报错也不应用但如果你的代码里混用了onUnmounted清理事件监听器就可能触发不一致行为。解决在onUnmounted里调用abortController.abort()中断所有正在进行的流式请求。不要依赖组件内部的标志位来阻止更新因为异步回调的执行时机不可控只有从根上取消请求才能保证卸载后不再有数据回流。如果你用的是fetch而不是 XMLHttpRequestAbortSignal的取消是即时的这是另一个不使用EventSource的理由。5.4 坑4Markdown 渲染高峰期卡顿现象AI 回复生成长代码块时页面在流式输出的最后几秒明显卡顿滚动不再流畅点击停止按钮要过一两秒才有反应。原因流式输出的每个 chunk 都在触发renderedHtml重新计算如果消息内容已经长出几十行代码highlight.js需要对整个代码块做词法分析。每次增量更新重复分析计算量呈线性增长最终拖垮主线程。解决两个手段配合。第一在MessageContent组件里对streaming true的内容关闭代码高亮只做基础 Markdown 转换。第二给高亮计算加防抖streaming变为false后延迟 300ms 再执行完整渲染。最后这个 300ms 的防抖还能顺带避免「连续快速输出时的中间帧闪烁」——多次重绘合并成一次视觉上反而更稳定。5.5 坑5页面刷新丢消息现象用户聊了半小时刷新页面后全部聊天记录消失回到空白页面。这对用户心智的打击几乎是毁灭性的。原因本地没有任何持久化消息数组只存在于内存中。以为用户聊天记录存在服务端实际上服务端只保持短期会话不会承担前端页面的历史展示责任。解决用浏览器的localStorage或sessionStorage做一层简单的本地缓存。localStorage适合保存跨会话记录但容量有限5MB 左右适合只存最近 20-50 条消息sessionStorage适合只保证刷新不丢。实现方法是监听messages.value的深度变化用watch配合JSON.stringify写入存储页面初始化时读取解析回填。需要注意watch要走deep: true否则数组内对象的属性变化不会触发持久化。6. 给聊天页面收尾的三个技巧完成度从 80 分到 95 分核心功能跑通之后决定用户体验上限的往往是这些「边角」习惯。第一个技巧是保存滚动位置。我在消息列表每次滚动时把scrollTop写进会话存储重新进入页面时恢复。如果你接入了会话切换这个值要跟着会话 ID 走。第二个技巧是 Web Worker 里做文本处理。大批量 Markdown 转换和高亮计算放到独立线程能显著减少主线程的卡顿感。注意marked和highlight.js都可以在 Worker 里运行唯一要处理的是 Worker 与主线程之间的消息投递格式。第三个技巧是给停止按钮加一个「动画反馈」点击停止后按钮变成「已停止」状态并禁用等流式请求完全断掉后再恢复原样。这样用户能明确知道自己的操作被接收了不会因为接口延迟断开而重复点击触发重复请求的翻车现场。我在每个聊天项目里都会保持一个习惯把流式接口的断连重试代码单独拆成一个工具模块不混入页面组件。这样当接口方调整事件格式、增加错误码时我只改一个文件。这个习惯帮我避过好几次「一个需求改了一整片组件」的麻烦。做 AI 聊天页面最怕的不是需求复杂而是把网络层的临时处理散落在 UI 代码里越到后面越难收拾。如果你正准备接第一个流式接口从上面的最小实现起步按坑位一个个排查相信你很快就能交付一个让用户觉得「回复很顺畅」的聊天页面。希望帮到你。本文还有配套的精品资源点击获取