1. 为什么是 EventSource:实时 AI 聊天的消息通道选型
做 AI 聊天相关的功能,第一步要解决的就是“消息怎么从服务端推到浏览器”。早期大家习惯用轮询,前端每隔两三秒发一次请求,问服务端“有没有新消息”。这种方式在传统业务里够用,但放到 AI 流式输出这种场景下就非常尴尬——大模型生成一段回答往往需要几秒到几十秒,轮询太频繁浪费请求,轮询太稀疏又会让用户觉得“怎么半天没反应”。我自己最早做类似功能时用过 WebSocket,后来换成 EventSource,才发现很多看似复杂的问题其实可以更轻量地解决。
1.1 实时交互的三种主流方案对比
我把主流方案放在一起对比过,它们的本质区别在于“连接模型”和“数据格式”:
- 轮询(Polling):前端定时发起 HTTP 请求,服务端不管有没有新数据都返回一次响应。实现最简单,但实时性差,请求浪费严重。适合低频、容忍延迟的场景,比如消息列表的自动刷新。
- WebSocket:全双工通信,客户端和服务端可以互相主动推送。功能最强大,但协议相对复杂,需要处理握手、心跳、断线重连、二进制帧等一堆细节。对于“AI 对话”这种“客户端说一句、服务端流式返回一串”的模式,WebSocket 的能力其实有一半用不上。
- EventSource(SSE):基于 HTTP 的单向流式通信,服务端可以持续向客户端推送数据。浏览器原生支持,自动重连,协议简单,用起来就像监听一个普通事件一样。
对于 AI 聊天悬浮窗这个场景,核心需求是“把大模型生成的内容以流式方式实时展示给用户”,本质上是一条单向通道:服务端 → 客户端。用户的问题通过普通 POST 或 GET 请求发送即可,不需要服务端主动往客户端以外的方向推送其他东西。所以 EventSource 在架构上是更贴合需求的选择。
提示:EventSource 的一个硬性限制是只能通过 GET 方法发送请求,所以携带参数通常要拼在 URL 上。如果必须用 POST,那就得走 fetch + ReadableStream 的方案,或者直接上 WebSocket。这点在项目初期就要想清楚,后面我会讲我的处理方式。
1.2 EventSource 的核心机制与使用边界
EventSource 本质上就是浏览器对“服务器推送”的一种标准化封装。服务端只要把 Content-Type 设置为text/event-stream,然后按照固定的格式持续输出数据,浏览器端的onmessage就会不断被触发。
它的几个特性在实战中非常关键:
- 自动重连:当网络抖动或者服务端主动断开连接时,浏览器会自动重新发起连接,不用自己写重连逻辑,这一点实测非常省心。
- 事件类型:除了默认的 message 事件,还可以通过
event:字段自定义事件类型,比如event: ping、event: error,前端可以用addEventListener分别监听。 - 断点续传:可以通过
Last-Event-ID头告诉服务端“我上次收到的消息 ID 是 xx”,服务端可以从这个位置继续推送,避免重复或丢失数据。AI 聊天场景下这个特性用得不多,但自由聊天类产品可以借此实现消息归档。 - 连接数量限制:HTTP/1.1 下浏览器对同一个域名的并发连接数有上限(一般是 6 个),EventSource 会占一个常驻连接。如果是 H2 或者多域名部署,这个限制基本可以忽略。
使用边界也很清楚:EventSource 是一次“长连接”,如果用户切后台太久,浏览器可能会自动断开,重连之后需要自己判断上下文状态。另外服务端如果一段时间没有数据输出,中间最好发心跳包保持连接存活,否则代理层可能会把空闲连接掐断。
2. 项目整体设计与准备
这个实战项目我假设的场景是:页面右下角有一个悬浮按钮,点击后展开一个迷你聊天窗口,用户输入问题后,由后端转发给大模型接口,大模型流式返回内容,前端通过 EventSource 实时渲染在窗口里。
2.1 功能清单
我最终实现的功能包括:
- 悬浮球 + 展开/折叠聊天窗
- 支持拖拽移动,位置在 localStorage 中记忆
- 用户输入问题,流式显示 AI 回复
- 流式返回过程中“停止生成”按钮
- 连接状态指示(连接中/已断开/重连中)
- 消息历史按会话维度保存在内存中,刷新页面后保留当前会话
这套功能覆盖了 AI 聊天悬浮窗的绝大部分核心诉求,背后涉及的逻辑可以复用到其他实时推送场景,比如通知中心、工单状态提醒、数据监控大屏等。
2.2 技术栈与项目结构
前端我用 Vue 3 + Vite,组合式 API +<script setup>语法,组件通信用 Pinia 管理会话状态。后端为了演示方便,用 Node.js + Express 写了一个模拟 SSE 接口,内部用定时器模拟流式吐字,方便没有大模型 API Key 的朋友也能直接跑起来看效果。如果你有真实的模型接口,把后端这段换成对模型 API 的转发即可。
项目结构大体如下:
├── src │ ├── api │ │ └── sse.ts # SSE 客户端封装 │ ├── components │ │ ├── ChatFab.vue # 悬浮球 │ │ ├── ChatWindow.vue # 聊天窗口主体 │ │ └── MessageItem.vue # 单条消息组件 │ ├── stores │ │ └── chat.ts # Pinia 会话状态 │ └── App.vue ├── server │ └── index.js # 后端 SSE 模拟接口 └── index.html2.3 后端 SSE 接口快速实现
后端核心代码其实很短,关键在于“持续输出但不关闭响应”。我直接用 Express 实现:
const express = require("express"); const app = express(); app.get("/api/chat-stream", (req, res) => { // 必须设置这些响应头,否则 EventSource 无法正常工作 res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" // 关掉 Nginx 缓冲,否则流式会卡顿 }); const question = req.query.message || ""; // 模拟一段拼好的回答,真实场景这里应该是真实模型的流式输出 const answer = `你刚才问的是:${question}\n这是一条模拟的流式回复。`; const chunks = answer.split(""); // 每 80ms 输出一个字,模拟真实模型的逐字产出效果 let index = 0; const timer = setInterval(() => { if (index >= chunks.length) { // 用 [DONE] 标记流式输出结束,前端据此关闭 loading res.write(`data: [DONE]\n\n`); clearInterval(timer); res.end(); return; } // SSE 协议约定的数据格式:data: 内容 + 两个换行符 res.write(`data: ${JSON.stringify({ content: chunks[index] })}\n\n`); index++; }, 80); // 客户端断开连接时清理定时器,避免服务端资源泄漏 req.on("close", () => { clearInterval(timer); res.end(); }); }); app.listen(3000, () => { console.log("SSE server running at http://localhost:3000"); });这里有两个细节值得注意。
第一,SSE 的数据格式非常严格:每一行必须以data:开头,数据结束必须以两个换行符\n\n作为终止标记。这是协议层面的要求,缺失任何一个换行符都有可能导致浏览器端事件不触发。
第二,我在响应头里加了X-Accel-Buffering: no。如果生产环境用 Nginx 做代理,默认 Nginx 会对响应做缓冲,导致 EventSource 变成“等到全部数据到齐才一口气发给前端”,流式效果直接失效。这一行可以关掉 Nginx 的代理缓冲。这是非常典型的“开发环境好好的,上线就变成一次性输出”的坑。
3. 核心代码实现:EventSource 客户端封装与悬浮窗组件
3.1 封装一个可复用的 SSE 客户端
直接在每个组件里 new EventSource 也能跑,但几轮写下来会发现问题:多个地方要监听不同事件、处理重连状态、清理连接,代码会越来越散。所以我建议单独封装一个工具类。
核心设计思路是:把“建立连接”“接收消息”“错误处理”“关闭连接”这几件事收敛到一个类里,对外提供简单的回调方法。同时兼容两种用法——一种是通过onmessage统一处理数据,另一种是支持自定义事件监听。
// src/api/sse.ts type EventSourceOptions = { url: string; onMessage?: (data: any) => void; onError?: (err: Event) => void; onOpen?: () => void; onDone?: () => void; }; class SSEConnection { private es: EventSource | null = null; private options: EventSourceOptions; constructor(options: EventSourceOptions) { this.options = options; } connect() { this.close(); this.es = new EventSource(this.options.url); this.es.onopen = () => { this.options.onOpen?.(); }; this.es.onmessage = (event) => { // 判断是否结束 if (event.data === "[DONE]") { this.options.onDone?.(); this.close(); return; } try { const parsed = JSON.parse(event.data); this.options.onMessage?.(parsed); } catch (e) { console.error("SSE 数据解析失败", e); } }; this.es.onerror = (err) => { // EventSource 内部会自动重连,这里主要做状态上报 this.options.onError?.(err); }; } close() { if (this.es) { this.es.close(); this.es = null; } } } export default SSEConnection;这里有个点需要特别说明:onerror回调触发时,EventSource 不一定会彻底死掉,它大概率会进入自动重连流程。所以不要在 onerror 里做太重的清理操作,否则会导致“重连还没开始就被你手动 close 掉”的情况。
State 管理我放在 Pinia 里,主要存会话消息列表和连接状态。每一次点击“发送”就 create 一个SSEConnection实例,把当前会话的 id 传给后端,后端根据会话 id 拼出上下文,然后开始流式返回。这个过程的整体逻辑是:
// stores/chat.ts 核心逻辑(节选) const messages = ref<Message[]>([]); const connectionStatus = ref<"idle" | "connecting" | "streaming" | "done">("idle"); let currentConnection: SSEConnection | null = null; function sendMessage(content: string) { // 先把用户消息推入列表 messages.value.push({ role: "user", content }); // AI 回复的消息先占位,后续流式内容持续追加 const aiMessage: Message = { role: "assistant", content: "" }; messages.value.push(aiMessage); connectionStatus.value = "connecting"; const query = encodeURIComponent(content); currentConnection = new SSEConnection({ url: `http://localhost:3000/api/chat-stream?message=${query}`, onOpen: () => { connectionStatus.value = "streaming"; }, onMessage: (data) => { aiMessage.content += data.content; }, onDone: () => { connectionStatus.value = "done"; }, onError: () => { connectionStatus.value = "error"; } }); currentConnection.connect(); }注意我用了encodeURIComponent处理 message 参数。如果用户输入包含中文、特殊符号或者空格,直接拼到 URL 上会出问题,甚至导致请求 400。这是实战中很容易踩的坑,很多人写完接口用英文测一切正常,一输入中文就废了。
3.2 悬浮窗组件结构
悬浮窗拆成三个组件是合理的:ChatFab(悬浮球)、ChatWindow(聊天窗)、MessageItem(单条消息)。三个组件通过父组件的状态协同工作。ChatWindow 固定挂载在右下角,用 Vue 的<Transition>做展开收起动画。
<!-- ChatWindow.vue 结构节选 --> <template> <Transition name="chat-pop"> <div v-if="visible" class="chat-window"> <div class="chat-header"> <span>AI 助手</span> <div> <span class="status-dot" :class="connectionStatus"></span> <button @click="$emit('close')">收起</button> </div> </div> <div class="chat-body" ref="bodyRef"> <MessageItem v-for="msg in messages" :key="msg.id" :message="msg" /> </div> <div class="chat-footer"> <textarea v-model="draft" @keydown.enter.exact.prevent="handleSend" placeholder="输入你的问题,Enter 发送" /> <button @click="handleSend" :disabled="sending">发送</button> </div> </div> </Transition> </template>这里有一个交互细节:当 AI 的流式内容不断追加时,聊天框需要自动滚动到底部,否则用户看到的是内容“被挤压”在上方,视觉效果很糟糕。我封装了一个scrollToBottom方法,放在 watch 里监听最后一条消息的 content 变化时触发。
watch( () => messages.value[messages.value.length - 1]?.content, () => { nextTick(() => { if (bodyRef.value) { bodyRef.value.scrollTop = bodyRef.value.scrollHeight; } }); } );有些朋友会直接用scroll-behavior: smooth,但在高频追加内容的场景下,平滑滚动反而会导致滚动跟不上,出现来回“抽搐”的感觉。实测用默认的瞬时滚动体验更好。
3.3 消息流式渲染与打字机效果
流式数据的渲染分两步:先把每个 chunk 追加到当前 AI 消息对象里,触发 Vue 的响应式更新;然后配合固定时间间隔的 CSS 闪烁光标,就可以做出“打字机”效果。
在 MessageItem 组件里,AI 回复内容展示时如果正在生成,光标会一直闪烁。这个实现不复杂,不需要额外引入打字机库:
<template> <div class="message-item" :class="message.role"> <div class="bubble"> <span>{{ message.content }}</span> <span v-if="isStreaming" class="cursor"></span> </div> </div> </template> <script setup> import { computed } from "vue"; const props = defineProps({ message: Object, isLast: Boolean }); const isStreaming = computed( () => props.isLast && props.message.role === "assistant" ); </script> <style scoped> .cursor { display: inline-block; width: 8px; height: 16px; background: #4f7cff; margin-left: 2px; animation: blink 0.8s step-end infinite; } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } </style>很多人纠结要不要用第三方打字机库,实测下来完全没必要。真实场景下 AI 模型的输出本身就不是均匀的——有时快有时慢,前端只要做到“来一个 chunk 渲染一个 chunk”,自然会产生打字机效果。强行用setInterval去控制显示速度,反而会造成内容堆积,体验不自然。
4. 悬浮窗的交互细节打磨
功能跑通之后,真正影响用户体验的反而是那些交互细节。悬浮窗不同于普通页面组件,它常驻在页面上方,做不好会打扰用户,做得顺手则会变成“日常习惯的一部分”。
4.1 拖拽与位置记忆
拖拽我用原生 pointer 事件实现,避免引入拖拽库。核心思路是:指针按下时记录初始位置,指针移动时计算偏移,更新悬浮球的位置;指针抬起时把最终位置写入 localStorage。
function onPointerDown(e: PointerEvent) { dragging = true; startX = e.clientX - posX; startY = e.clientY - posY; window.addEventListener("pointermove", onPointerMove); window.addEventListener("pointerup", onPointerUp); } function onPointerMove(e: PointerEvent) { if (!dragging) return; posX = Math.min( window.innerWidth - fabSize, Math.max(0, e.clientX - startX) ); posY = Math.min( window.innerHeight - fabSize, Math.max(0, e.clientY - startY) ); } function onPointerUp() { dragging = false; localStorage.setItem("chatFabPos", JSON.stringify({ x: posX, y: posY })); window.removeEventListener("pointermove", onPointerMove); window.removeEventListener("pointerup", onPointerUp); }这里有两个坑值得说一下。第一,Math.max和Math.min的边界判断不能少,不然悬浮球可以被拖到屏幕外,拖回来后发现找不到了。第二,pointerup需要在window上监听而不是在元素上,否则鼠标移动过快导致指针移出悬浮球时,抬起事件会丢失,拖拽状态就卡住了。
4.2 折叠与展开
悬浮球与聊天窗的切换,我用一个v-model:visible来控制。用户点击悬浮球展开聊天窗,点击聊天窗头部“收起”按钮或点击悬浮球则收起。展开后是否需要自动聚焦输入框?这个细节很重要——用户点击悬浮球的目的通常就是提问,自动聚焦能减少一步操作。
<script setup> import { watch, nextTick, ref } from "vue"; const visible = defineModel("visible", { type: Boolean, default: false }); const inputRef = ref<HTMLTextAreaElement | null>(null); watch(visible, async (val) => { if (val) { await nextTick(); inputRef.value?.focus(); } }); </script>自动聚焦可以做得更精细一点:如果上次对话还没结束,不要强行聚焦,避免打断用户正在阅读流式输出的注意力。
4.3 自动弹出策略
有些运营场景希望用户进入页面后自动弹出聊天窗。这里我的建议是“克制”:只在满足特定条件时弹出,比如新用户首次访问、或者用户停留超过 30 秒。我用 localStorage 记录弹出次数,最多每 24 小时自动弹出一次,避免用户每刷新一页都被骚扰。
function shouldAutoPopup(): boolean { const last = localStorage.getItem("chatAutoPopupDate"); if (!last) { localStorage.setItem("chatAutoPopupDate", new Date().toDateString()); return true; } return last !== new Date().toDateString(); }一句话总结:自动弹出功能要做成“引导”,而不是“打扰”。最理想的效果是用户没有注意到它是自动出现的,只是刚好想咨询时发现它就在那里。
5. 常见问题与排查技巧实录
实战中踩过的坑,整理成一张速查表,方便大家直接对照排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| EventSource 收不到任何消息 | 响应头 Content-Type 不是 text/event-stream | 检查后端响应头设置 |
| 消息一次性全部出现,没有流式效果 | Nginx 缓冲未关闭 | 设置X-Accel-Buffering: no |
| 连接 1 分钟左右自动断开 | 代理层空闲超时 | 服务端增加定时心跳 |
| 刷新页面后消息丢失 | 未持久化消息历史 | 接入 localStorage 或 IndexedDB |
| 请求 URL 带中文直接报错 | 未对参数编码 | 使用 encodeURIComponent |
| 输入框回车后页面刷新 | 缺少.prevent修饰符 | 使用@keydown.enter.exact.prevent |
| 用户反馈聊天卡顿/掉字 | 网络拥塞时数据缓存堆积 | 可考虑用fetch + ReadableStream替代 |
5.1 EventSource 连接被意外断开
EventSource 的自动重连机制在实际使用中有一个隐藏问题:它默认的重连间隔是 3 秒,但服务端如果彻底不可用,它会一直以 3 秒的间隔反复尝试,给后端造成压力。可以通过服务端返回自定义重连时间来控制,SSE 协议支持发送retry:字段来设定重连间隔。
// 服务端在响应流中发送 retry 字段 res.write(`retry: 5000\n\n`);这样 EventSource 在收到 retry 字段后,会自动使用 5 秒作为重连间隔,直到连接建立成功为止。
5.2 组件卸载后连接未关闭
Vue 组件的onUnmounted里必须调用currentConnection.close()。如果不做这一步,会出现一个非常诡异的现象:组件已经卸载了,但 devtools 里 Network 面板还挂着一条 pending 状态的 EventSource 请求,而且消息还在继续推送。这是因为 EventSource 实例是独立于组件存在的,它只跟 JS 运行环境生命周期挂钩,不跟随组件销毁而销毁。
onUnmounted(() => { currentConnection?.close(); });5.3 请求带不上参数或鉴权头
EventSource 天然不支持自定义 headers,如果服务端需要 token 鉴权,方案有这么几个:
- 把 token 放在 URL query 上(简单粗暴,但有 token 暴露风险)
- 使用 cookie 方案(推荐,浏览器会自动携带同源 cookie)
- 服务端把鉴权信息编码在短时有效的 token 参数中,配合服务端校验
我倾向于采用“短时有效 token 拼在 query 上”的方式:先通过普通 POST 接口获取一个有效期为 1 分钟的临时 token,然后 EventSource 的 URL 上带上这个 token。这样比把长期 token 暴露在 URL 里安全很多。
5.4 页面长时间挂机连接失效
如果用户打开页面去忙别的事,半小时后回来发现悬浮窗显示断线,这通常是因为笔记本电脑合盖或者网络切换导致连接被系统断开。EventSource 的自动重连在这种情况下可能也失效了,因为浏览器可能没有触发 onerror。处理方式是添加一个定时器,每 30 秒检测一次 connection 状态,如果显示 non-connecting 且用户当前有未完成的消息,就手动 close 掉重建连接。
6. 从“能跑”到“好用”:几个拿得出手的小细节
说实话,很多人按教程写完这段代码,功能是能用的,但和“体验好”之间还有一段距离。这里分享几个我做同类项目时总结出来的细节处理,可以显著提升完成度。
6.1 会话上下文管理
真实的 AI 聊天悬浮窗,用户的对话往往不是孤立的单轮内容而是连续的多轮对话。如果后端只是简单地把当前问题发给模型,模型回答会缺乏上下文,显得“答非所问”。我在 Pinia 里维护了一个上下文数组,只保留最近 10 条消息(避免 token 超限),发送时拼接为 Prompt 的一部分。
这里有一个度的问题:上下文带得越长,模型回答质量越高,但首字延迟也会更明显。从实际经验看,10 条以内是最佳平衡点。
6.2 停止生成
大模型生成一个很长回答时,用户难免觉得“已经够了”。我在输入框旁边加了一个“停止”按钮,实现方式是:点击后调用currentConnection.close(),同时在消息末尾追加一条“已停止生成”的灰色提示。这个逻辑非常简单,但如果没有这个功能,用户只能等模型把话说完,体验会差很多。
6.3 消息持久化
刷新页面后聊天记录消失,对用户来说是一个毁体验的行为。我用 localStorage 做了简单持久化,key 为当前页面路径,value 是最近 50 条消息。刷新后自动读取并恢复。考虑到聊天消息可能包含敏感信息,我在实现时也做了清理策略:页面关闭后超过 24 小时自动清空记录。
说个更实际的场景:很多后台管理系统的“操作指引”浮窗,如果能记住用户上次问过什么,下次打开时直接显示在对话历史里,这种“记忆感”会让用户觉得系统真的很懂他,而不只是摆设。
6.4 移动端适配
悬浮窗在 PC 端和移动端的布局策略完全不同。PC 端我限制聊天窗宽度为 360px、高度为 480px;移动端则直接将聊天窗铺满全屏,悬浮球尺寸也缩小。判断机型我用一个简单的window.innerWidth判定,不需要引入额外的响应式库。
const isMobile = window.innerWidth < 768;移动端的输入法弹出会挤压页面高度,我建议聊天窗用height: 100dvh而不是100vh,这样能自适应浏览器地址栏的收起和展开,避免出现底部按钮被键盘顶出屏幕的问题。
6.5 无消息时的空状态
一个很小的细节,但体现完成度:首次打开聊天窗时,显示一句“您好,我是智能助手,有什么可以帮您的?”比一个空白列表友好得多。这个空状态还可以兼做“推荐问题”的入口,放两三个快捷提问按钮,用户点一下就能快速试玩,转化率会有明显提升。这个设计思路对降低试用门槛非常有帮助。
7. 最后想说的话
EventSource 这套方案在“AI 对话”场景下,用很小的成本解决了 80% 的核心问题。它不是万能的——如果你需要客户端和服务端双向高频互推,WebSocket 仍然是更合适的选择。但对于“消息推送 + 实时展示”这个窄场景,EventSource 的轻量性和浏览器原生支持会带来更高的开发效率,维护成本也更低。
我在实际开发中还遇到过一些奇奇怪怪的环境相关问题,比如开发环境跑得好好的,打包部署到生产环境后 EventSource 请求 404。排查到最后发现是静态服务器没有配置/api的代理转发规则,白白浪费了半天时间。使用这种方案时,建议把“检查 Nginx 或网关代理配置”列入上线 checklist,可以少走不少弯路。
这个项目后续还可以继续扩展,比如接入用户头像体系、对接真实大模型接口、支持 Markdown 渲染,逻辑上不会有太大改动,组件化的好处就在这些地方体现出来了。如果你正准备做一个 AI 助手类的前端交互,完全可以从悬浮窗这个小切口开始,按这套思路逐步完善,踩坑会少很多。