AI 爆款文章创作器前端的 SSE 处理不依赖 EventSource,而是用 fetch + ReadableStream 手动解析 text/event-stream:SSE 事件要随 POST 携带 taskId 和参数,EventSource 只支持 GET;后端用 Spring Boot 的 SseEmitter 按事件名推送,前端要区分 event: 与 data: 字段。打字机效果靠「流式接收 + 定时渲染」两层协作,把「数据到达」与「字符展示」解耦,避免网络抖动造成文字卡顿。
一、为什么不用 EventSource
EventSource 是浏览器原生 SSE 客户端,支持自动重连和 onmessage 回调,看似省事,项目弃用的原因如下:
| 对比项 | EventSource | fetch + ReadableStream |
|---|---|---|
| 请求方法 | 仅 GET | GET / POST 均可 |
| 自定义请求头 | 不支持 | 支持,可带 Authorization |
| 断线重连 | 自动 | 手动实现 |
| 中止控制 | 有限 | AbortController 精确控制 |
创作器的生成接口是 POST /api/tasks/{id}/generate,请求体要传阶段参数,EventSource 仅支持 GET,直接出局。fetch 方案多写几十行解析代码,换来自定义请求头和错误处理。
二、后端 SSE 的输出格式
后端用 Spring Boot 的 SseEmitter 推送,每条消息带事件名和数据:
event: AGENT3_STREAMING
data: {"taskId":"20260818001","delta":"人工智能"}
event: AGENT3_COMPLETE
data: {"taskId":"20260818001","content":"..."}
SseEmitter 每次 send() 拼上 event: 与 data: 字段,\n\n 分隔消息。
三、前端流式解析器的实现
前端封装一个 sseClient,接收响应体并按事件分发:
- 发起
fetch,signal挂到 AbortController 上,用于中途取消; - 取
response.body.getReader(),循环调用reader.read()拿二进制块; - 用
TextDecoder解码字节,{ stream: true }处理跨块的多字节字符; - 按
\n\n切分完整消息,解析event:与data:字段; - 流结束或收到
[DONE]哨兵时关闭流。
export async function createSSEStream(
url: string,
body: object,
onEvent: (event: string, data: string) => void,
signal?: AbortSignal
) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal
})
if (!res.ok || !res.body) throw new Error(`SSE connect failed: ${res.status}`)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const dispatch = (raw: string) => {
const eventLine = raw.split('\n').find(l => l.startsWith('event:'))
const dataLine = raw.split('\n').find(l => l.startsWith('data:'))
if (!dataLine) return
onEvent(eventLine ? eventLine.slice(6).trim() : 'message', dataLine.slice(5).trim())
}
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let sep: number
while ((sep = buffer.indexOf('\n\n')) !== -1) {
dispatch(buffer.slice(0, sep))
buffer = buffer.slice(sep + 2)
}
}
if (buffer) dispatch(buffer)
}
3.1 多字节字符的坑
TextDecoder 的 { stream: true } 必须加。UTF-8 中文一字 3 字节,TCP 分块可能从字节中间断开,不带此参数会解出乱码。
四、打字机效果的实现
打字机效果分两段:数据层边接收边把增量 append 到响应式变量,渲染层用定时器控制可见字符数。二者拆开,网络块到达不均时直接渲染会忽快忽慢。
let fullText = ''
const displayText = ref('')
let index = 0
let timer: number | null = null
function startTypewriter(speed = 30) {
stopTypewriter()
timer = setInterval(() => {
displayText.value = fullText.slice(0, index + 1)
index++
if (index >= fullText.length) stopTypewriter()
}, speed)
}
function stopTypewriter() {
if (timer !== null) clearInterval(timer)
timer = null
}
displayText 用 v-html 渲染带 Markdown 高亮的增量内容,word-break 与固定行高防止字符跳动,长段落按行裁剪避免布局抖动。
4.1 与流式到达的衔接策略
到达比定时器快时,fullText 持续膨胀,定时器按自己节奏消费,最终追平;到达慢时,index 超出 fullText 长度,加一句 if (index >= fullText.length) return 暂停等待,避免渲染空字符。生成结束调用 stopTypewriter() 显示完整文本,光标闪烁用 CSS ::after 实现。
这套方案在正文与大纲两个阶段复用,用户看到的文字匀速出现,交互体感稳定一致。
常见问题(FAQ)
Q1:EventSource 明明更简单,为什么项目不用?
EventSource 只能发 GET、不能带自定义头,满足不了带参请求与鉴权需求。
Q2:流式接收时中文乱码怎么办?
TextDecoder 加 { stream: true },多字节字符跨块时由解码器缓存补齐。
Q3:打字机效果要不要逐字渲染整篇 Markdown?
不逐字渲染全文,只对当前增量做处理,长文按行截断避免布局抖动。