尧图精选

Vue3 前端实现 Spring-AI 流式对话:从 SSE 协议到打字机效果全解析

🕒 发布时间:2026/9/9 4:57:48 📁 来源:尧图网络
我先把结论放在前面Spring-AI 后端把流式接口调通只是完成了上半场真正让用户觉得“这AI好用”的关键往往在 Frontend 这一层。很多项目挂在后端已经能逐 token 输出但前端要么一次性渲染、要么边输出边卡顿体验直接打对折。这篇文章我会从流式协议的底层原理讲起一直写到 Vue3 前端的完整落地实现包括消息状态管理、中断控制、渲染性能优化以及我实际过程中踩过的一堆坑。如果你正在做 AI 聊天助手、基于大模型的内容生成工具或者单纯想把 Spring-AI 后端接到 Vue3 前端这篇文章应该能帮你少走不少弯路。1. 流式对话的前后端协作逻辑1.1 为什么流式输出是 AI 对话的刚需先想一个问题你打开 ChatGPT 的时候如果文字是一次性蹦出来的你会觉得爽吗大概率不会。流式输出的价值不只是“看起来酷”它背后有一个非常实际的心理机制——用户等待反馈时如果超过 2 秒没有任何动静焦虑感就会急剧上升而如果文字持续在跳动即使完整输出需要 10 秒用户也愿意等。这就是“进度可见性”的威力。放到产品层面来说流式输出直接决定了用户对 AI 响应速度的感知。同样是 5 秒完成一次回答一次性渲染会让用户觉得“卡了 5 秒”流式渲染会让用户觉得“这 AI 反应真快”。所以流式对话不是锦上添花而是 AI 应用的体验底线。从技术实现角度看Spring-AI 在后端通过 SSEServer-Sent Events协议把大模型的输出 Token 实时推送给客户端。这套协议本身并不复杂但前端如何处理这些持续到达的数据片段并在 Vue3 的响应式系统里高效渲染出来这里面的门道远比想象中多。1.2 SSE 与 WebSocket 的取舍做流式对话时很多人第一个想到的是 WebSocket。我在早期项目里也用过但后来发现 SSE 在大多数场景下是更合理的方案原因其实很朴素SSE 基于 HTTP不需要额外的握手协议和心跳维护服务端实现成本极低Spring-AI 天然支持。它是单向通道服务端往客户端推数据恰好符合“用户提问 → AI 回答”的对话模型。自动重连机制是浏览器原生能力不需要自己写断线重连的逻辑。WebSocket 的优势在于双向通信但如果业务里没有“客户端和服务端高频互推”的需求引入 WebSocket 就是给自己找麻烦——连接管理、心跳保活、跨域鉴权、异常恢复每一块都是额外的工作量。所以我的建议很简单做 AI 对话优先用 SSE别被“WebSocket 更高级”这种想法带偏了。1.3 Spring-AI 返回的数据格式Spring-AI 的流式接口通常返回的是FluxString通过 SSE 协议传输。前端 fetch 拿到响应体后会看到类似这样的原始数据流data:{id:chatcmpl-123,object:chat.completion.chunk,choices:[{delta:{content:你好}}]} data:{id:chatcmpl-123,object:chat.completion.chunk,choices:[{delta:{content:我是}}]} data:{id:chatcmpl-123,object:chat.completion.chunk,choices:[{delta:{content:AI助手}}]} data:[DONE]每一行data:前缀后面跟的是一段 JSON其中choices[0].delta.content就是模型本次输出的增量文本。前端要做的事情本质上就是按行读取数据去掉data:前缀解析 JSON把delta.content追加到当前消息的 content 里。这个流程听起来简单但实际实现时会有不少细节问题——比如如何处理多行数据中的换行符、如何判断流结束、如何在下一次提问时保留上下文。这些我会在后面的实操环节逐一展开。2. Vue3 前端工程基础搭建2.1 基于 Vite 快速初始化项目如果你是从零开始搭这个项目第一步是创建一个 Vue3 工程。Vite 是目前 Vue3 项目的首选构建工具冷启动快、热更新流畅开发体验比 Webpack 时代好了一个数量级。npm create vitelatest ai-chat-frontend -- --template vue cd ai-chat-frontend npm install npm run dev跑完这三步一个能用的 Vue3 项目就起来了。如果你之前还在用vue create我建议尽早切换到 Vite——Vue 官方已经把 Vite 作为默认推荐生态适配也最顺畅。2.2 配置 src 别名项目创建后第一件事我会把别名配上。这个配置在官方模板里已经默认存在但如果你用的是旧版模板需要自己手动加。在vite.config.js中import { fileURLToPath, URL } from node:url import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } })同时要在jsconfig.json中补充路径映射让 IDE 识别别名{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }这个配置的收益是长期的。项目组件一多../../components/这种相对路径会让你怀疑人生而/components/一眼就知道文件在哪。顺手再做一件事在src下创建views、components、utils、api、stores等目录后续代码按职责分门别类放好。2.3 为什么这里要用 fetch 而不是 axios这是整个前端实现里最重要的一个技术选型。很多人习惯性用 axios 发请求但轮到 SSE 流式场景axios 用起来非常别扭。axios 的核心是处理“完整响应”——它默认会等整个响应体接收完毕再触发回调。虽然 axios 也提供了onDownloadProgress事件但处理流式数据时你拿到的是一个 ProgressEvent要手动从这个事件里扣数据片段使用体验很糟。而 fetch API 配合ReadableStream是浏览器原生支持的流式读取方案直接告诉你“数据块到了”语义清晰代码也更好维护。更关键的一点是axios 打包体积不小而 fetch 是原生能力。一个 AI 对话项目引不引 axios 对首屏体积有明显影响。所以我强烈建议 SSE 场景直接用 fetch 方案不要被“axios 是标配”的惯性思维带着走。3. 流式对话前端核心实现3.1 最容易翻车的设计失误我见过很多流式对话项目前端代码写得没问题但运行时总是出现怪毛病要么渲染内容丢字、要么偶发解析异常排查半天发现错在“数据格式假设”上——不少人默认整个 SSE 响应体是一次性到达的或者按\n分完行就万事大吉了。这里必须一句话说清楚SSE 的数据完整性在流式传输里是不被保证的。你只读到一个“半个JSON片段”是完全正常的——比如{id:abc123,choices:[{delta:{content:你好世界}}]}这个 JSON 在网络上传输时可能被拆成两半先到前半截后半截还在路上。如果代码里每次数据块到达就直接 JSON.parse那你就会时不时看到一个解析失败的错误。所以正确的实现方式是需要一套“数据积累 → 按行切分 → 完整行才解析 → 剩余残片暂存”的拆包逻辑。下面这段 lookupUp 的代码就是标准解法——核心思路是先把字节累积到缓冲区里每次只取\n之前的完整行执行解析不完整的行留在缓冲区等下一块数据到来。3.2 核心代码用 fetch 消费流式数据下面这段代码可以直接抄它就是流式对话前端的核心引擎。先定义消息的数据模型// 消息数据结构 export function createUserMessage(content) { return { id: user-${Date.now()}, role: user, content, status: done } } export function createAssistantMessage() { return { id: assistant-${Date.now()}, role: assistant, content: , status: pending } }消息分两种角色用户消息和助手消息。助手消息初始content为空状态为pending随着流式数据到达不断追加内容流结束后状态变为done。再看核心的流式请求方法// utils/sse.js export async function fetchChatStream({ url, messages, onMessage, onDone, onError, signal }) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream }, body: JSON.stringify({ messages }), 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 { 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]) { onDone() return } try { const json JSON.parse(data) const content json.choices?.[0]?.delta?.content || if (content) { onMessage(content) } } catch (err) { console.warn(解析失败等待下个数据块, err) } } } onDone() }几个关键点我必须展开说第一TextDecoder(utf-8)必须配置{ stream: true }。不加这个参数解码器遇到一个多字节字符被拆到两个数据块时会直接返回乱码中文内容会显得“缺胳膊少腿”。这个参数告诉解码器“数据还没完先别急着转最终结果”。第二缓冲区里的残片处理。buffer.split(\n)之后lines.pop()是因为最后一行大概率是不完整的把它留在buffer里等下一个数据块到达后再拼接。这个处理是整段代码的灵魂。第三onDone要避免重复触发。代码里在[DONE]标记处和正常while循环结束后都会调用onDone()实际使用时需要在外层调用时判断done状态避免状态重复更新。这个细节我会在后面的完整组件代码里体现。3.3 打字机效果的实现与渲染优化拿到增量文本后最基础的做法是message.content delta。但在 Vue3 中直接这样追加有个性能隐患content是响应式数据每次赋值都会触发依赖它的组件重新渲染。如果流式数据频率很高有的模型一秒钟推 30~50 个 token整个消息列表会以极高的频率被重渲染页面很容易卡顿。我的建议是把“数据存储”与“展示文本”分开。用一个普通非响应式变量存完整内容用一个ref存“已展示的内容”通过定时器控制展示进度这样渲染频率就由你控制而不是被网络节奏牵着走。这是在 Vue 组件中通过自定义useTypingEffect组合式函数实现的核心逻辑// composables/useTypingEffect.js import { ref, onBeforeUnmount } from vue export function useTypingEffect(interval 30) { const displayedText ref() const targetText ref() let timer null const updateTarget (text) { targetText.value text if (timer) return timer setInterval(() { if (displayedText.value.length targetText.value.length) { displayedText.value targetText.value.slice(0, displayedText.value.length 2) } else { displayedText.value targetText.value clearInterval(timer) timer null } }, interval) } const reset () { displayedText.value targetText.value if (timer) { clearInterval(timer) timer null } } onBeforeUnmount(reset) return { displayedText, updateTarget, reset } }这个函数做的事情很纯粹updateTarget不断把新的完整文本传进来内部用定时器逐步把targetText的内容“披露”给displayedText。每次渲染的内容从完整文本变成“当前这一帧该显示多少字”渲染频率被牢牢控制在 30ms 一次也就是每秒约 33 帧流畅度完全够用。是否要加打字机效果取决于你的产品定位。如果目标用户是普通消费者打字机效果能带来明显的“智能感”如果是效率工具用户如程序员写代码场景反而希望内容尽可能快地显示出来这时可以把interval调小或者直接实时渲染。我的做法是提供可配置选项默认开启但允许用户关闭。3.4 中断控制的完整实践流式对话还有一个用户体验的关键点用户发出去之后如果发现问错了需要能立刻中断。这个需求在 fetch 方案里对应的原生能力是AbortController。使用方式非常直接const controller new AbortController() const { signal } controller // 请求时传入 signal await fetchChatStream({ url: /api/chat, messages: history, signal, // 就是这里 onMessage: appendDelta, onDone: finishMessage, onError: handleError }) // 用户点击停止按钮时 controller.abort()注意一个细节abort()会抛出一个AbortError异常所以必须在请求的catch里做区分处理不要把它当作普通网络错误弹给用户。try { await fetchChatStream({ ... }) } catch (err) { if (err.name AbortError) { // 用户主动中断静默处理即可 message.status done } else { // 真正的错误 message.status error message.error err.message } }3.5 完整可运行的消息列表组件把上面的所有逻辑组合起来就是一个完整可运行的聊天对话组件。它包含了用户发送、流式接收、中断控制、加载状态这几个核心功能!-- components/ChatWindow.vue -- script setup import { ref } from vue import { fetchChatStream } from /utils/sse import { createUserMessage, createAssistantMessage } from /utils/message import { useTypingEffect } from /composables/useTypingEffect const messages ref([]) const input ref() const isStreaming ref(false) const controller ref(null) // 流式只针对当前最新一条助手消息做打字机效果 const { displayedText, updateTarget, reset } useTypingEffect() async function sendMessage() { const content input.value.trim() if (!content || isStreaming.value) return input.value const userMsg createUserMessage(content) messages.value.push(userMsg) const assistantMsg createAssistantMessage() messages.value.push(assistantMsg) isStreaming.value true reset() // 组装上下文只传文本内容 const history messages.value.map(m ({ role: m.role, content: m.content || m.displayedContent || })) controller.value new AbortController() try { await fetchChatStream({ url: /api/chat, messages: history, signal: controller.value.signal, onMessage: (delta) { assistantMsg.content delta updateTarget(assistantMsg.content) }, onDone: () { assistantMsg.status done assistantMsg.content assistantMsg.content || 无内容 isStreaming.value false }, onError: (e) { assistantMsg.status error assistantMsg.error e.message isStreaming.value false } }) } catch (err) { if (err.name ! AbortError) { assistantMsg.status error assistantMsg.error err.message } else { assistantMsg.status done } isStreaming.value false } } function stopStreaming() { if (controller.value) { controller.value.abort() } } /script template div classchat-container div classmessage-list div v-formsg in messages :keymsg.id :class[message, msg.role] div classbubble template v-ifmsg.role assistant !-- 当前这条消息如果是流式中显示打字机文本否则显示完整文本 -- {{ msg.status streaming ? displayedText : msg.content }} /template template v-else {{ msg.content }} /template span v-ifmsg.status error classerror{{ msg.error }}/span /div /div /div div classinput-area textarea v-modelinput placeholder请输入你的问题... keydown.enter.exact.preventsendMessage /textarea button clickisStreaming ? stopStreaming() : sendMessage() {{ isStreaming ? 停止 : 发送 }} /button /div /div /template这里关于displayedText的用法需要澄清一下打字机效果在每一帧只更新displayedText的值而且这个值本身被绑定到模板上如果消息列表里有 N 条历史消息它们会同步重渲染。如果消息特别长、列表特别多这种重渲染代价就不小了。4. 消息管理、组件通信与性能优化4.1 消息列表的性能治理方案在流式对话场景中消息列表有两个典型的性能压力源一是历史消息数量增长二是当前流式消息高频刷新。如果这两者耦合在一起页面就会出现输入卡顿、滚动跳动等问题。我常用的方案是“当前流式消息独立渲染历史消息走列表”正在流式输出的消息不放进messages数组而是单独用一个currentMessageref 变量保存模板中单独渲染它。流式开始前先把上一条完整消息 push 进messages数组流式过程中只更新currentMessage流结束后再把currentMessagepush 进messages。这样做的好处非常明显流式中间态的极端高频更新都只作用于当前这一个组件节点而不是整棵列表树。当历史消息有几百条时这个差异可能是 3~5 倍的渲染性能差距。如果历史消息规模继续膨胀还可以考虑在消息列表上加虚拟滚动。但虚拟滚动组件本身实现的复杂度不低对于普通聊天场景历史消息不超过几百条Vue3 的 diff 机制已经优化得足够好不一定非得上虚拟滚动。这里也顺便说一个和 Vue3 渲染机制相关的点Vue3 响应式系统对数据更新的触发是同步的但在渲染层面它有微任务调度和更新的批处理batch。也就是说即使你在一个 tick 里连续给content赋了 100 次值最终渲染时也只会触发一次真实 DOM 更新数据流式和渲染天然是解耦的。这个机制从根上缓解了频繁更新对 DOM 的压力。但代价是两大块成本并不可忽略一是content每次赋值的响应式依赖追踪成本这块通常不大二是模板里所有依赖content的表达式会把整棵组件子树拖下水这才是大头。所以把流式消息“拆出去独立渲染”的关键收益正是把依赖范围从整棵列表缩小到单个组件。4.2 父子组件数据交互的边界划分在我这种分层设计里消息列表的职责从下到上是最底层的基础消息组件只负责展示一条消息中间的消息列表组件管理多条消息的布局和滚动最上层的聊天容器组件负责发起请求、接收流式数据、持有发送/停止按钮的交互逻辑。子组件通过props拿到消息对象通过defineEmits把“重发”“复制”这类事件抛给父组件整套数据流非常清晰!-- components/MessageItem.vue -- script setup const props defineProps({ msg: { type: Object, required: true } }) const emit defineEmits([resend, copy]) function handleCopy() { navigator.clipboard.writeText(props.msg.content) emit(copy, props.msg.id) } function handleResend() { emit(resend, props.msg) } /script父组件在模板里绑定事件监听MessageItem v-formsg in messages :keymsg.id :msgmsg copyonCopy resendonResend /这种单向数据流的模式最大的好处是可维护性。消息对象只会在父组件里被修改子组件永远不知道自己在该“更新哪个字段”——它只需要拿到msg并渲染。将来如果要把“消息”替换成“代码块消息”或“工具调用消息”只需要扩展msg对象结构组件之间的接口完全不用变。4.3 对话上下文的维护与 API 请求体设计多轮对话的上下文维护很多新手容易搞错。正确的做法是前端把整个对话消息数组传给后端后端负责精简和组装 Prompt。我当时第一版是前端自己拼 Prompt结果后端的角色设定和系统指令完全没法融入改一次需求前端代码全要动。后来把上下文的组装逻辑完全交还给后端——前端只负责传“原始的对话数组”后端在服务端把系统指令、历史摘要、知识库检索结果都拼装好。这个设计从源头上避免了前后端职责混淆。综合上面的设计最终请求体的结构大致是这样{ messages: [ { role: system, content: 机器人角色设定由后端注入 }, { role: user, content: 你好介绍一下你自己 }, { role: assistant, content: 你好我是智能助手... } ], stream: true }注意一个容易被忽略的点当前用户消息应该放在数组最后且不发 assistant 的中间态。有些实现会把“当前正在生成的半截文本”也发给后端当上下文这会让模型的理解产生不必要的混乱因为半截文本通常不是一句完整的话。5. 常见问题与排查技巧实录5.1 中文乱码和编码问题SSE 流式数据最常见的坑之一就是中文乱码。产生原因有两类一类是后端响应头的Content-Type没有指定charsetutf-8字符串被按平台默认编码解析。这类问题在前端代码里做再多的处理也没用因为编码信息在响应头就已经丢掉了——建议后端接口固定返回text/event-stream; charsetutf-8。另一类是前端解码器的问题就是前面提到的TextDecoder必须加{ stream: true }。很多教程里不写这个参数中文长文本偶发乱码排查起来特别隐蔽。这个参数的作用我再说得直白一点TextDecoder.decode(value, { stream: true })告诉解码器“当前传入的这段字节流可能不完整如果有半个多字节字符先暂存在内部等下一个数据块到了再自动拼接”。不传这个参数遇到半截中文字符它会直接替换成。由于 SSE 数据块在传输层可能在任何位置被拆分所以这个参数几乎是必加的。5.2 数据不刷新浏览器缓冲问题如果你发现后端日志里明明在持续输出前端却迟迟看不到文字像是“憋”了很久才一次性出现极有可能是浏览器或者代理层对 SSE 做了缓冲。最典型的问题是 Nginx 默认开启了proxy_buffering on会缓冲后端发来的数据直到缓冲区满了或连接结束才一起转发给前端。解决方式是在 Nginx 配置里关闭这个代理缓冲location /api/ { proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; }还有一个容易被忽略的点Spring Boot 后端开启压缩后SSE 偶尔也会出现异常。流式数据本身已经是按 Token 切分的文本再经过 Gzip 压缩代理层解压时很可能发生缓冲。如果碰到诡异的数据堵塞排查时顺手把server.compression.enabledfalse试一次就能快速定位问题。前端.vue文件里中文乱码、数据卡住这类问题一般两三分钟就能定位但如果是 Nginx 缓冲这种“环境问题”没有经验的人可能排查一整天也找不到病根。上面这几条是我踩过坑之后沉淀下来的排查清单。5.3 连接中断与自动重试浏览器原生的EventSource自带重连机制但我们使用 fetch 自定义实现 SSE 消费时就丢掉了这个原生能力。如果服务端在流式半途断开比如网络抖动前端会直接收到一个TypeError: Failed to fetch这时的用户体验非常不好——消息停在那里状态却还是 pending。我的处理策略是分三层流结束前断开把消息状态标记为error在 UI 上给一个“重试”按钮。流结束后校验完整性检查assistantMsg.content是否为空如果为空且状态不是done直接报错。网络层自动重试对“连接建立失败”的情况即fetch阶段就抛错做一次简单重试间隔 500ms 左右。对“连接已经建立中途断开”的情况不做自动重试因为流式上下文已经发送给后端重试会导致重复生成。5.4 中断按钮的状态同步我调试时遇到过一个挺隐蔽的问题点击“停止”之后后端确实不再推送数据了但前端的isStreaming状态还是true发送按钮仍处于禁用状态。原因是controller.abort()触发的是 AbortError而我的fetchChatStream函数内部这个异常是在await reader.read()抛出并不会走到正常的onDone()回调。所以组件外层必须把isStreaming置为false的逻辑放到catch分支里处理并且要区分AbortError和其他错误。总结成一段代码非常值得抄到你的项目里catch (err) { if (err.name AbortError) { assistantMsg.status done } else { assistantMsg.status error assistantMsg.error err.message || 连接中断 } } finally { isStreaming.value false }5.5 一次线上事故流式中途白屏最后分享一个我真实遇到过的案例。某次线上反馈说对话到一半白屏控制台大量报错。排查后定位到原因后端某次异常返回了一段不完整的 JSON前端的解析逻辑把它当成了普通异常丢弃了。我原本的代码长这样try { const json JSON.parse(data) // ... } catch { console.warn(跳过不完整数据) }问题在于不完整数据被丢弃后消息缺少了结尾部分但状态却被正常置为done。用户看到的就是一段“戛然而止”的回答没有任何报错提示感知上像是 AI 答到一半罢工了。修复方式很简单为每条消息增加一个“是否收到 [DONE] 标记”的校验只有收到 [DONE] 才允许把状态置为 done。let receivedDone false // 在解析到 [DONE] 标记时 receivedDone true onDone() // 在流结束时 if (!receivedDone) { onError(new Error(流式响应未正常结束)) }这个校验非常重要它是流式对话前端的最后一道防线可以把很多底层异常转化为用户可见的错误提示避免“半截消息被当成完整回答”的尴尬。6. 序列化、数据一致性与其他隐藏坑点6.1 消息对象被响应式代理后的隐患Vue3 的ref/reactive会把对象包装成 Proxy。如果你把一个响应式对象传给fetchChatStream函数再在函数内部直接修改它的属性修改是可以生效的但存在一个隐患在非组件上下文里如果你访问这个代理对象的属性并把它作为普通对象序列化可能得到{}。一个我实际见过的场景const msg reactive({ content: hello }) const json JSON.stringify(msg) // 可能是 {content:hello}但如果对象有 getter 就会出问题解决办法是序列化时使用toRaw或者直接传递普通对象import { toRaw } from vue const rawMsg toRaw(msg)这个坑在小型项目里可能永远踩不到但一旦你的项目开始做状态管理、跨组件共享数据就很容易中招。我的习惯是凡是需要传给纯 JS 函数非组件的数据统一用toRaw或普通对象。领域模型和数据传输对象彻底分开不混在一起。6.2 思考过程推理内容与答案内容的区分如果用的大模型支持“思维链”比如 DeepSeek 的reasoning_content字段后端返回的数据里除了正常的内容还可能会带一段“思考过程”。如果前端不处理这段思考过程会直接混进答案文本里用户体验非常糟糕。标准的做法是const reasoning json.choices?.[0]?.delta?.reasoning_content || const content json.choices?.[0]?.delta?.content || reasoning_content单独存到一个变量里在 UI 上以折叠面板或“查看思考过程”的形式展示默认收起。这不算新功能但很多团队的 AI 对话产品上线后才发现漏了这层处理导致用户看到一堆类似“嗯用户问的是……”的内心独白显得 AI 很不专业。6.3 多轮对话中系统指令的覆盖问题如果你把system指令由前端传给后端有一个隐藏的坑用户可能在对话中说“忽略你之前的设定”之类的话模型会把用户输入的功能覆盖掉系统角色设定。虽然这属于后端安全问题但前端在传参时也要有意识地处理一些“提示注入”场景。基础的做法是后端把系统指令拼接在用户输入之前而不是简单地把用户输入和后端设定并行传到模型。6.4 保存与恢复历史记录聊到一半刷新页面历史记录丢了是 AI 产品最常见的失望时刻。最简单的保存方案是把消息数组存到localStorage每次消息变化都写入一次。这里要注意频率流式更新时messages是全量变化如果每条增量都写入localStorage同步操作页面会卡顿。折中的方案是流式过程不写存储只在一条消息完整结束后写入一次。6.5 Markdown 渲染与 XSS 风险AI 的回复绝大多数是 Markdown 格式所以消息列表的展示组件里通常会引一个 Markdown 渲染库比如markedhighlight.js。这里有个非常容易忽视的安全问题AI 返回的内容里可能包含恶意 HTML 标签或 JavaScript 代码。虽然正常情况下大模型不会主动生成攻击代码但如果用户故意诱导模型输出img srcx onerroralert(1)渲染时就有风险。解决方案是渲染 Markdown 时必须做净化处理。我推荐dompurifyimport DOMPurify from dompurify import { marked } from marked const renderedHTML DOMPurify.sanitize(marked.parse(msg.content))DOMPurify会把script、onerror这类危险内容直接过滤掉。这个安全细节千万不要省上线后被安全扫描发现的风险项十有八九出在这里。7. 工程化实践请求层封装与项目目录组织7.1 流式请求的统一封装上面给出的fetchChatStream是底层函数。在实际项目中我建议按业务再包一层把 URL、参数拼装、错误码处理都放进去。这样组件代码只关心业务逻辑不关心协议细节。// api/chat.js import { fetchChatStream } from /utils/sse export function sendChatMessage({ messages, onMessage, onDone, onError, signal }) { return fetchChatStream({ url: /api/chat, messages, onMessage, onDone, onError, signal }) }如果后端的接口格式有变只需要改api/chat.js这一处。如果再往后有多模态、文件上传、知识库检索、语音输入等需求也都按这个模式扩展每个业务一份独立 API 文件上层组件的代码保持极简。7.2 按功能的目录结构到这一步项目结构可以整理成下面这样这也是我比较推荐的组织方式src/ api/ chat.js components/ ChatWindow.vue MessageItem.vue composables/ useTypingEffect.js utils/ sse.js message.js views/ HomeView.vue App.vue main.jscomposables放可复用的组合式函数utils放纯工具函数api放接口封装组件按功能放到components。这种结构能够支撑项目一直演进到中大型规模不必在后期做大规模重构。7.3 环境变量管理后端接口地址不要写死在代码里通过环境变量管理这已经算前端基本功了。# .env.development VITE_API_BASE_URL/api # .env.production VITE_API_BASE_URLhttps://your-domain.com/api使用时const url ${import.meta.env.VITE_API_BASE_URL}/chat这个配置能保证不同环境下前端代码零改动。唯一的注意点是环境变量必须以VITE_开头才能被 Vite 暴露给前端代码。8. 写在最后从能跑到好用差的就是细节前端的流式对话代码量其实不大真正的难点全在细节里数据拆包的完整性能不能保证、中断和出错的状态能不能正确收敛、打字机效果有没有引入不必要的性能损耗、系统指令和多轮上下文会不会被上下文冲掉、组件间的数据流向是否清晰、渲染的安全过滤有没有做好、以及“半截消息当完整回答”这类边界情况能不能被拦下来。我自己做这个项目最大的一个体会是AI 应用的前端并不难难在对协议层的深刻理解和工程细节的持续打磨。流式对话的核心不是把 fetch 的流读出来而是把“用户看到的”做成一个稳定、可信、不吓人的产品体验。能做到这一点即使后端模型能力一般用户也会觉得这个 AI 很聪明反过来说哪怕模型再强前端崩一次、乱一次码、停一次不动用户对它的信任就全没了。后面有机会我打算把“流式对话的移动端适配”“基于 Web Worker 的流式数据解析优化”“消息压缩与上下文窗口管理”这几个方向也单独写一写。如果你在落地过程中有更好的方案或者踩过新奇的坑欢迎一起交流互相提个醒。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →