资讯动态

Vue3 + EventSource 实现实时AI聊天悬浮窗:SSE流式通信与踩坑实践

发布时间:2026/10/2 13:13:59 来源:尧图企业网站定制
做公司内部后台系统时运营提了个需求能不能不管当前在哪个页面随时都能喊出一个AI助手问点东西不要跳到单独聊天页。于是就有了这个基于 Vue3 EventSource 的实时AI聊天悬浮窗方案。它不依赖第三方UI库从流式通信、悬浮窗拖拽到消息状态管理全部自己实现整个组件可以直接塞进现有Vue3后台管理系统也可以单独拎出来做成业务系统的智能客服入口。这篇文章适合谁正在折腾Vue3项目、想给系统加AI助手却不知道怎么处理流式输出的人已经用 EventSource 做过推送但卡在鉴权、重连、Nginx缓冲这些细节上的人以及刚接触SSE想弄明白“为什么AI会议都是打字机一样蹦字”的初学者。我会把整条链路拆开讲先讲为什么选SSE再写服务端接口然后封装前端流式客户端最后落地成悬浮窗组件并附上完整代码和踩坑记录。1. 为什么要把AI聊天塞进悬浮窗通信选SSE而不是WebSocket1.1 悬浮窗解决的真实场景一个后台管理系统里用户往往同时开着多个菜单页订单列表、用户详情、数据报表。如果AI助手只在某个独立路由里遇到问题时就得切走页面回来还要重新找刚才看的数据思考链路断了。悬浮窗的好处是它始终悬浮在内容之上用户在当前页面就能提问、复制回答、继续手头操作。我做过一个实际案例在ERP系统的商品编辑页右侧挂了一个AI悬浮窗业务员可以直接把商品名称丢给AI让它生成标题优化建议不用去新开一个AI官网再复制粘贴。上线后高频功能是“答疑”和“内容润色”这类场景不需要复杂交互核心就两个问得进去、答得出来而且回答最好是流式的让用户感觉模型“正在打字”等待焦虑会轻很多。悬浮窗这个交互形态技术上的本质是一个全局固定定位组件难点不在“悬浮”本身而在三件事它必须脱离当前路由页面的样式隔离不能被页面的overflow: hidden或transform影响。它要能被拖来拖去位置最好记住下次打开还在原来的地方。它不能阻塞页面的滚动和点击但面板弹出时又要能拦住面板区域内的点击。这些在Vue3里有标准解法Teleport挂到body、原生鼠标事件实现拖拽、localStorage记忆位置。后面第4节我会给出完整代码。1.2 EventSource到底比WebSocket强在哪很多人一听到“实时”就想到 WebSocket但AI聊天这个场景用 EventSourceSSE更合适。先理解两者的本质区别。WebSocket 是一条全双工通道客户端和服务端都能随时往对方发数据SSE 基于 HTTP是单向通道只能服务端往客户端推送客户端要传数据走普通 HTTP 请求就行。那为什么AI聊天用SSE够了因为聊天流程是典型的“一问一答”用户输入消息通过一次 POST 请求发给服务端。服务端把整段回复拆成小块持续推给客户端。客户端收到一段拼一段展示打字机效果。整个过程客户端只在“发消息”时需要上行其余时间都在被动接收。SSE天然就是给这种“服务端流式下推”设计的不需要像WebSocket那样手动实现心跳、重连、消息帧格式浏览器原生 EventSource API 就自带断线重连。我整理了一个选型对比方便你看清楚维度EventSource (SSE)WebSocket通信方向单向服务端到客户端全双工协议HTTP / HTTPS独立的 ws / wss 协议自动重连浏览器原生支持需要自己写重连逻辑自定义 Header原生 EventSource 不支持支持文本消息天然按 UTF-8 文本帧需要约定消息格式服务端实现成本Express / FastAPI 直接 write 即可需要维护连接状态和心跳适用场景消息推送、AI流式输出、日志实时展示在线游戏、协同编辑、IM强双向交互结论是AI聊天这种“服务端持续输出、客户端等待接收”的场景SSE就是最省事的方案。除非你需要同时做“服务端主动推送消息”和“客户端与服务端高频双向交互”才需要考虑WebSocket。1.3 这套方案的技术边界当然SSE不是没有限制。它是 HTTP 长连接浏览器对同一域名下的连接数有限制Nginx 默认会缓冲响应必须关掉缓冲原生 EventSource 只能发 GET 请求且没法自定义请求头。这几个问题在后面“踩坑实录”里都会讲到这里先有个印象就行。我的建议是如果你的AI服务在公网且有产品化诉求前端用“fetch ReadableStream”模拟SSE会比原生EventSource更可控如果你只是内网工具、不需要token鉴权直接用原生EventSource最快。本文第3节会把两种方式都讲清楚。2. 先搭一条能跑的SSE流式接口前端做得再漂亮没有一个能持续吐数据的接口都是白搭。我建议先把服务端流式接口跑通用 curl 验证完再回来写前端。这样出问题时能快速定位是网络层、协议层还是渲染层。2.1 SSE协议规范和响应头SSE 说到底就是服务端不关闭连接持续往响应体里写特定格式的文本。协议规定响应头必须包含Content-Type: text/event-stream。建议设置Cache-Control: no-cache避免浏览器缓存。连接要保持所以通常带Connection: keep-alive。每条消息以\n\n空行分隔每行以data:开头。可以选配event:自定义事件类型、id:事件ID、retry:重连间隔。一个最简单的SSE数据帧长这样data: {role: assistant, delta: 你} data: {role: assistant, delta: 好} data: [DONE]前端用new EventSource(url)打开后默认监听onmessage拿到的就是每帧data:后面的内容。如果是JSON前端自己JSON.parse。2.2 用Express写一个模拟AI流式接口我这里是前端为主后端接口只用于本地验证所以用 Express 写一个最简单的模拟接口。它会按固定间隔逐字输出一行话完全模拟GPT流式返回的效果。const express require(express) const app express() const sleep (ms) new Promise((resolve) setTimeout(resolve, ms)) app.get(/api/chat/stream, async (req, res) { res.setHeader(Content-Type, text/event-stream; charsetutf-8) res.setHeader(Cache-Control, no-cache, no-transform) res.setHeader(Connection, keep-alive) // 关键告诉Nginx等中间层不要缓冲SSE res.setHeader(X-Accel-Buffering, no) res.flushHeaders() const userText req.query.message || const reply 我是模拟AI助手你刚才问的是${userText}。这是一条流式返回的消息用来测试SSE链路是否正常。 try { // 逐字推送 for (let i 0; i reply.length; i) { const payload JSON.stringify({ delta: reply[i] }) res.write(data: ${payload}\n\n) await sleep(30) } // 结束标记 res.write(data: [DONE]\n\n) res.end() } catch (err) { // 客户端断开时写入会抛错静默处理即可 res.end() } }) app.listen(3000, () { console.log(SSE mock server running at http://localhost:3000) })代码里有一个容易被忽略的点res.flushHeaders()。如果服务端框架有响应缓冲调用它可以把响应头立刻刷给客户端否则客户端可能一直停留在pending状态等所有数据写完才一次性拿到。另外真实业务里客户可能中途关掉面板此时req.on(close)会触发。上面这个模拟接口简单就不加复杂清理逻辑了你接到真实大模型服务时记得在close事件里断开上游连接避免模型计费还在继续。2.3 用curl验证和常见误区接口写完之后不要着急写前端先用 curl 验证curl -N --no-buffer http://localhost:3000/api/chat/stream?messagehello-N和--no-buffer是告诉 curl 不要缓冲输出这样你能看到数据逐字蹦出来。如果一切正常终端里会先出现data: {delta:我}然后每隔几十毫秒多一行最后以[DONE]结束。这个验证环节非常重要。很多人前端代码写了一大堆结果发现是后端Content-Type没设置对导致浏览器不触发onmessage或者Nginx缓冲导致数据攒了一大包才推过来。用curl能提前暴露这些问题。一个常见误区是把Content-Type写成application/json前端解析失败。SSE 要求必须是text/event-stream。另一个误区是使用res.send()一次性发送那等于普通HTTP响应流式效果完全出不来。必须用res.write()多次写入。3. 前端流式客户端从EventSource到fetch流的封装3.1 原生EventSource的连用与短板如果你的项目在后端没有单独鉴权接口、用户信息靠Cookie传递那么原生 EventSource 是最快的接入方式const es new EventSource(/api/chat/stream?messagehello) es.onmessage (event) { if (event.data [DONE]) { es.close() return } const json JSON.parse(event.data) console.log(json.delta) } es.onerror () { // 浏览器会自动重连不需要手动处理 }代码很简洁而且浏览器原生支持断线自动重连。但是有两个事它做不了第一不支持自定义请求头。EventSource构造函数只接受一个URL走的是普通GET请求没办法塞Authorization: Bearer xxx。如果你的项目是通过JWT做鉴权token通常在Authorization头里原生EventSource就直接抓瞎了。第二只能GET。一些AI服务接的是SSE协议但要求通过POST提交较长的提示词原生EventSource同样无能为力。那为什么我标题还写“EventSource实战”因为我说的是“EventSource技术”是一种数据交互模式。实际落地时我们用fetch配合ReadableStream去解析服务端推送的text/event-stream效果上跟原生EventSource等价但能解决鉴权和POST问题。3.2 fetch ReadableStream 完整封装下面这个类是我在实际项目里用的版本支持携带token、手动断开、事件解析、断线重连。你可以直接复制改改就能用。type StreamHandlers { onDelta: (text: string) void onDone: () void onError: (err: Error) void } export class SSEStreamClient { private controller: AbortController | null null private retryTimer: ReturnTypetypeof setTimeout | null null private retryCount 0 private stopped false private url private handlers: StreamHandlers | null null private token async connect(url: string, handlers: StreamHandlers, token ) { this.url url this.handlers handlers this.token token this.stopped false this.abortCurrent() this.controller new AbortController() try { const response await fetch(url, { method: GET, headers: { Accept: text/event-stream, ...(token ? { Authorization: Bearer ${token} } : {}), }, signal: this.controller.signal, }) if (!response.ok || !response.body) { throw new Error(SSE request failed, status: ${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 frames buffer.split(\n\n) // 最后一段可能是不完整的一帧留到下一轮再拼 buffer frames.pop() || for (const frame of frames) { const text this.parseFrame(frame) if (text [DONE]) { handlers.onDone() return } if (text) { handlers.onDelta(text) } } } // 服务端正常结束 handlers.onDone() } catch (err) { if (this.stopped) return if (err instanceof DOMException err.name AbortError) return handlers.onError(err as Error) this.scheduleReconnect() } } // 解析一个完整帧中的 data 字段 private parseFrame(frame: string): string { const lines frame.split(\n) const dataLines lines .filter((line) line.startsWith(data:)) .map((line) line.slice(5).trim()) if (dataLines.length 0) return const raw dataLines.join(\n) if (raw [DONE]) return raw try { const json JSON.parse(raw) return json.delta ?? json.content ?? json.text ?? raw } catch { return raw } } private scheduleReconnect() { if (this.stopped) return // 指数退避避免服务端异常时客户端不断重连 const delay Math.min(1000 * Math.pow(2, this.retryCount), 15000) this.retryCount 1 this.retryTimer setTimeout(() { if (this.stopped) return this.connect(this.url, this.handlers!, this.token) }, delay) } private abortCurrent() { if (this.controller) { this.controller.abort() this.controller null } } disconnect() { this.stopped true this.abortCurrent() if (this.retryTimer) { clearTimeout(this.retryTimer) this.retryTimer null } } }解析SSE帧最核心的一点是reader.read()返回的每个 chunk 不一定正好是一整帧。比如一帧可能是“data: 你\n\n”但网络层可能会把这帧拆成两次返回或者把好几帧合在一个 chunk 里。所以我用buffer先把数据攒起来每次按\n\n切出完整帧把不完整的尾部留在buffer里。这是非常容易踩坑的地方很多人直接用response.text()去读结果读完整个流之后才能拿到全部内容流式效果就丢了。3.3 断线重连与页面可见性恢复我在上面的类里实现了指数退避重连但还有一类特殊断线情况页面切到后台浏览器冻结了标签页的请求。等用户切回来时这个连接可能已经断了但是浏览器不一定会触发错误回调。处理办法是监听页面可见性变化回到页面时主动检查连接状态必要时重建连接document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { if (needReconnect) { sseClient.connect(/api/chat/stream?message lastQuestion, handlers, token) } } })这里的lastQuestion是当前正在回答的那个问题。用户在后台等了几分钟切回来时连接早就断了重连后服务端不知道你已经收到多少内容所以会出现重复回答。这个问题我留在第6节展开。4. 悬浮窗组件的拖拽、挂载与全局集成4.1 Teleport挂载和全局注册悬浮窗组件不能只存在于某个页面里否则路由一切换就没了。我的做法是把它做成一个全局组件在系统根布局里放一次。先看组件模板入口template Teleport tobody div classai-fab :stylefabStyle mousedownonMouseDown AiIcon / /div div v-ifpanelVisible classai-panel :stylepanelStyle !-- 聊天面板内部结构 -- /div /Teleport /templateTeleport tobody是关键。它的作用是把这个组件的DOM直接挂到document.body下而不是嵌套在当前页面某个position: relative的容器里。这样做避免了两个问题后台管理系统常在外层容器设置overflow: hidden或transform: translate这会让position: fixed失效或者被裁切。Teleport 出去后悬浮窗只受 body 约束。页面自带的z-index栈不会影响悬浮窗你只要给面板设置足够高的z-index即可。全局注册很简单在main.ts里import { createApp } from vue import App from ./App.vue import AiChatWidget from ./components/AiChatWidget.vue const app createApp(App) app.component(AiChatWidget, AiChatWidget) app.mount(#app)然后在根布局的模板里放一次template div classlayout Sidebar / MainContent / AiChatWidget / /div /template这样不管路由怎么切换悬浮窗一直存在。4.2 手写拖拽逻辑和位置记忆拖拽逻辑不需要引入vue-draggable一类的库用原生鼠标事件就够了代码量很小。const position ref({ x: window.innerWidth - 90, y: window.innerHeight - 120, }) const dragging ref(false) const startPosition ref({ x: 0, y: 0 }) const startMouse ref({ x: 0, y: 0 }) function onMouseDown(e: MouseEvent) { dragging.value true startPosition.value { ...position.value } startMouse.value { x: e.clientX, y: e.clientY } // 这里不直接用事件监听在组件上 // 是为了避免鼠标移出按钮后拖拽中断 window.addEventListener(mousemove, onMouseMove) window.addEventListener(mouseup, onMouseUp) } function onMouseMove(e: MouseEvent) { if (!dragging.value) return const dx e.clientX - startMouse.value.x const dy e.clientY - startMouse.value.y position.value { x: clamp(startPosition.value.x dx, 0, window.innerWidth - 56), y: clamp(startPosition.value.y dy, 0, window.innerHeight - 56), } } function onMouseUp() { if (!dragging.value) return dragging.value false window.removeEventListener(mousemove, onMouseMove) window.removeEventListener(mouseup, onMouseUp) localStorage.setItem(aiChatPosition, JSON.stringify(position.value)) } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) }这里有个细节window.addEventListener要在mouseup时移除不然每次拖拽都会叠加一个监听器时间长了内存泄漏而且拖拽会越来越卡。初始化时读取本地存储const saved localStorage.getItem(aiChatPosition) if (saved) { try { position.value JSON.parse(saved) } catch { // 解析失败就使用默认位置 } }4.3 如何接入已有的Vue3后台系统市面上很多Vue3后台管理系统比如基于若依、jeecg这类项目它们的布局容器样式比较重路由切换也会销毁页面组件。悬浮窗集成有两种姿势第一种直接放进根布局。像上面说的在布局组件里引入AiChatWidget /这种方式最稳所有路由都能看到。第二种如果只想在特定页面显示可以用useRoute判断const route useRoute() const visible computed(() route.path.startsWith(/admin))但我不建议用这种方式控制悬浮窗因为悬浮窗价值就在于“始终可用”页面限制了就没意义了。一个更隐蔽的问题是有些后台系统在根布局外层还有一层iframe嵌入悬浮窗挂在父文档的body上iframe 内部页面点击事件会被 iframe 边界挡住。如果你遇到“点击悬浮窗没反应”的情况先确认是不是通过 iframe 加载的页面。解决思路是把悬浮窗做成可在iframe内独立挂载的微组件或者在父文档统一挂载而不是强行塞到iframe里。这块依赖具体项目结构我就不展开说了。5. 聊天消息状态机与流式渲染细节5.1 用状态机管理“生成中”消息聊天面板的UI看起来简单实际上最难处理的是消息状态。一个AI回复从“等待”到“逐字输出”再到“结束/失败”至少有三个状态。如果不用状态管理会出现“回复还没结束就显示完成”“失败后还在渲染残留增量”这类诡异问题。我定义的消息结构type MessageStatus pending | streaming | done | error interface ChatMessage { id: number role: user | assistant content: string status: MessageStatus }流程是这样的用户点发送先把用户消息push进列表状态为done。再push一条空的 assistant 消息状态为pending。流式客户端每次收到delta就往这条 assistant 消息的content后面追加同时把状态改成streaming。收到[DONE]状态改为done。报错时状态改为error并在UI里给出“回答失败点这里重试”的按钮。发送逻辑核心代码async function sendMessage(content: string) { const userMsg: ChatMessage { id: msgId, role: user, content, status: done, } const assistantMsg: ChatMessage { id: msgId, role: assistant, content: , status: pending, } messages.value.push(userMsg, assistantMsg) inputText.value scrollToBottom() sseClient.connect(/api/chat/stream?message encodeURIComponent(content), { onDelta: (text: string) { assistantMsg.content text if (assistantMsg.status ! streaming) { assistantMsg.status streaming } // 节流滚动 scrollToBottom() }, onDone: () { assistantMsg.status done }, onError: () { assistantMsg.status error }, }, userToken) }这里有一个Vue3响应式的关键点assistantMsg是一个存储在refChatMessage[]里元素直接修改它的属性是响应式的因为ref内部会对整个数组做响应式转换。这样代码看起来简单但后面第6节会提到性能隐患。5.2 打字机效果的实现与滚动策略流式输出本身就是打字机效果不需要额外setInterval去模拟。你只需要保证每次delta到达后页面能刷出来最新内容。这里有一个小优化如果AI回答很长一次delta只有一两个中文字符却触发一次组件更新每秒可能更新20到30次UI压力较大。方案是对渲染频率做节流下面第6节细说。滚动策略也有讲究。如果每次增量都滚动到底部用户想回看上面的内容时滚动条会被不断拽下去。我做了一个简单判断只有当用户当前滚动位置接近底部时才自动滚动到底部否则不打扰。const chatContainer refHTMLElement | null(null) function isNearBottom(): boolean { const el chatContainer.value if (!el) return true return el.scrollHeight - el.scrollTop - el.clientHeight 80 } function scrollToBottom() { requestAnimationFrame(() { const el chatContainer.value if (el isNearBottom()) { el.scrollTop el.scrollHeight } }) }5.3 停止生成、重新生成与消息清空用户看了一半不想等了得有一个“停止生成”按钮。实现核心是调用SSEStreamClient.disconnect()然后立刻把当前正在streaming的消息状态改成done因为用户主动停止后再显示一个旋转图标就没有意义了。function stopGenerate() { sseClient.disconnect() const last messages.value[messages.value.length - 1] if (last last.status streaming || last?.status pending) { last.status done } }重新生成要复杂一点。你得找到最后一条用户消息把它的位置作为起点删掉它后面所有assistant回复再重新走发送逻辑function regenerate() { const lastIndex messages.value.findLastIndex((m) m.role user) if (lastIndex -1) return messages.value.splice(lastIndex 1) const lastUserContent messages.value[lastIndex].content sseClient.connect(/api/chat/stream?message encodeURIComponent(lastUserContent), { // 同上面 sendMessage 的 handlers }) }findLastIndex是ES2023新增的数组方法Vue3项目的构建工具一般都会转译。如果你担心兼容性自己写个for循环从后往前找也不难。6. 踩坑实录我在这套方案里掉过的五个坑6.1 EventSource无法自定义Authorization头这是第一个劝退原生EventSource的问题。用new EventSource(url)发请求时浏览器只会发GET请求而且不允许你设置任何自定义Header。如果你的登录态放在localStorage里每次API请求都靠Authorization: Bearer xxx鉴权那么原生EventSource会直接返回401。有人会想那我把token放到URL上不就行了new EventSource(/api/chat/stream?token token)能跑但不推荐。token写在URL里会出现在Nginx访问日志、浏览器历史记录、网关日志里等于把钥匙挂在门外面。我的解决方案就是前面第3节写的fetch ReadableStream封装。fetch允许自定义Header也允许你读response.body的流效果上完全可以替代EventSource。如果项目不想引自定义类也可以分两步先用普通HTTP请求换一个临时一次性token再用这个token拼URL最后用原生EventSource。但这样做绕了一圈会给后续排查增加复杂度。6.2 页面切后台后连接被冻结用户点了发送AI回复然后切到别的标签页查看资料过一会儿回来发现界面停住了或者一直停在“生成中”的状态。这就是前面提到的浏览器后台冻结问题。Chrome、Edge这类浏览器为了省资源对不可见标签页的网络请求会做限流甚至挂起。SSE这种需要服务端持续推送的长连接一旦页面切后台数据可能就停滞了。页面重新可见时连接又不会自动恢复。我的修复思路是监听document.visibilitychangedocument.addEventListener(visibilitychange, () { if (document.visibilityState visible) { const lastMsg messages.value[messages.value.length - 1] if (lastMsg lastMsg.status streaming) { // 重建连接手动请求继续生成 sseClient.disconnect() sseClient.connect(currentUrl, handlers, token) } } })但要注意重建连接后服务端很大概率会从头开始重新生成整段回答造成消息重复。这就要看你们后端AI服务是否有“从断点续推”的能力。如果没有一个保守的降级方案是检测到页面从后台返回且连接已经断了就把当前assistant消息状态改为“中断”并给用户一个“重新生成”按钮让用户自己决定要不要继续。至少不会出现屏幕上残留着一个永远转圈的状态。6.3 Nginx缓冲导致SSE响应迟迟不刷新本地开发一切正常部署到服务器后发现AI回答不是逐字出现而是等很久之后一次性全部冒出来。这在绝大多数情况是因为Nginx把SSE响应缓冲了。Nginx默认会缓冲上游响应攒够一定大小或者连接关闭后才发给客户端。而SSE是持续小片段写入正好被缓冲逻辑卡住了。解决方案有两个你可以根据权限选第一改Nginx配置关闭对应location的缓冲location /api/chat/stream { proxy_pass http://node-server:3000; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; }第二如果运维不同意改Nginx在后端响应头加X-Accel-Buffering: no这是Nginx能识别的响应头能针对单个请求关闭缓冲res.setHeader(X-Accel-Buffering, no)我在第2节Express代码里已经加了这个头。真实项目里如果还有网关层比如Kong、APISIX之类也需要确认它们有没有对SSE做特殊处理最好的验证方法就是本地curl接口正常部署后curl服务器域名异常那问题基本就在中间层。6.4 多次重连造成消息重复SSEStreamClient里有断线重连机制。假设网络抖了一下客户端自动重连发给服务端的还是同一个问题服务端可能又从第一字开始重新输出。此时页面上就会出现两条互相交错的AI回复状态混乱。要解决消息重复最简单有效的方式是引入“生成会话ID”。发送消息时前端生成一个UUIDconst sessionId crypto.randomUUID() sseClient.connect(/api/chat/stream?sessionId sessionId message encodeURIComponent(content), handlers)后端根据sessionId判断如果这个会话已经生成过一部分可以选择从断点继续也可以直接拒绝重连。如果后端不改那前端就需要在重连时先清掉旧assistant消息sseClient.onReconnect () { const last messages.value[messages.value.length - 1] if (last last.status streaming) { last.content } }我在实际项目中同时做了两件事前端带sessionId后端在缓存里存已推送的序列号。前端重连时把lastEventId带给后端后端只推送最新补集。这个方案比单纯清空重来体验好很多但需要前后端配合。6.5 高频流式更新引发的性能问题流式输出如果每秒触发20次assistantMsg.content text而这个属性又被整条消息对象的响应式代理监听Vue会在每次更新时执行依赖收集和组件重渲染。短消息没问题但如果AI一次输出2000字页面可能会掉帧尤其后台系统本身DOM已经很多的时候。有两种优化思路。思路一降低响应式更新频率。使用节流函数每100毫秒最多把最新内容同步到响应式状态一次。打字机效果依然是连续的因为人眼每秒能看到10帧就已经很流畅了。let tempContent let lastCommitTime 0 function onDelta(text: string) { tempContent text const now Date.now() if (now - lastCommitTime 100) { assistantMsg.content tempContent lastCommitTime now scrollToBottom() } }在onDone时强制提交一次保证最后剩余内容不丢。思路二数据结构上用shallowRef。如果你不想让组件每次因为整条消息对象深层变化而重渲染可以把消息列表放在shallowRef里流式更新时手动替换messages.value引用。不过这样代码会繁琐一些对于大多数场景节流已经够用。我的体会是性能问题要等真的出现了再优化。刚开始做的时候老老实实把代码写清楚别一上来就上一堆节流、shallowRef。等你在浏览器Performance面板里看到长文本回复明显卡顿再按上面的思路动手这时候你能直观感受到优化前后的差异。最后分享一个我自己的做事顺序先把消息状态机画出来再封装流式客户端最后写UI。顺序反了的话你会在UI和通信之间来回改。比如先写拖拽、输入框这些视觉层写到一半发现流式消息状态对不上了又要回头改组件结构很折腾。而先把状态机定义清楚每条消息是什么状态、哪些操作会引起状态迁移再去接EventSource数据基本一次就能跑通。这套代码的逻辑和思路希望能帮到你遇到具体问题也欢迎在评论区一起讨论。

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

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

免费获取报价 →
↑