上周接了个需求:给后台加一个 AI 对话窗口,要求消息一个字一个字往外蹦,像打字机那样。听起来不难,真上手 SSE 才知道坑在哪。记几笔,给后面要做的同学省点时间。
先说背景
后端给的是一个 SSE 接口,前端这边用 Vue3 + <script setup>。我一开始想当然,以为 EventSource 一把梭就完了。结果第一个坑就来了。
坑一:EventSource 不能带 POST,也不好带 header
我们的对话接口要传一长串上下文,得用 POST,还要在 header 里塞鉴权 token。EventSource 只支持 GET,header 也加不了。折腾半天放弃,改用 fetch + ReadableStream 自己读流:
javascript
const res = await fetch('/api/chat', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ messages }),
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// 下面要按行切 data: 块
}
坑二:一个 chunk 不等于一条 SSE 消息
我最早天真地拿到 chunk 就 JSON.parse,直接报错。后来打日志才发现,一次 read() 拿到的可能是半条消息,也可能是两条半。SSE 是按 \n\n 分隔事件、data: 开头的,必须自己拿 buffer 缓着按分隔符切,切不出完整的就留在 buffer 里等下一轮。
ini
const parts = buffer.split('\n\n')
buffer = parts.pop() // 最后一段可能不完整,留着
for (const part of parts) {
const line = part.replace(/^data:\s*/, '')
if (line === '[DONE]') return
const json = JSON.parse(line)
current.value += json.delta // 追加到响应式变量
}
这个 buffer.pop() 留尾巴的细节,没踩过坑真想不到。
坑三:响应式数组整体替换,DOM 全量重渲染
我一开始每来一个字就 messages.value = [...messages.value] 整个替换,长对话下肉眼可见卡。后来改成只动最后一条消息对象的 content 字段,让 Vue 的细粒度响应只更新那一个节点,顺多了。
一个没做好的地方
中断逻辑我做得很糙。用户点"停止"我只是把 reader cancel 掉,但后端那边其实还在跑,token 照烧。理想做法是发个 abort 信号给后端真正掐断,这块我留了个 TODO 一直没补。
模型这次我直接走的讯飞 MaaS,现成接口调,省得自己部署推理。前端同学接 SSE 还有啥独门技巧,评论区聊聊。