SSE 流式通信和打字机效果实现方法详解(前端流式渲染实战)

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,接收响应体并按事件分发:

  1. 发起 fetch,signal 挂到 AbortController 上,用于中途取消;
  2. 取 response.body.getReader(),循环调用 reader.read() 拿二进制块;
  3. 用 TextDecoder 解码字节,{ stream: true } 处理跨块的多字节字符;
  4. 按 \n\n 切分完整消息,解析 event: 与 data: 字段;
  5. 流结束或收到 [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?

不逐字渲染全文,只对当前增量做处理,长文按行截断避免布局抖动。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
小码农的头像小码农认证作者

相关推荐

返回顶部