SSE(Server-Sent Events)实现ai对话框

一、介绍

SSE(Server-Sent Events )是 HTML5 标准中引入的一种在浏览器和服务器之间建立单向实时通信的技术。它允许服务器主动向客户端推送数据,而不需要客户端反复发送请求(与轮询或长轮询不同)

  • 单向通信:服务器 → 客户端。
  • 协议:基于 HTTP(通常是 HTTP/1.1)。
  • 内容类型text/event-stream
  • 浏览器原生支持 :使用 JavaScript 的 EventSource 对象。

1.1 示例

服务端(Node.js 示例)

js 复制代码
// server.js
const http = require('http');

http.createServer((req, res) => {
  if (req.url === '/sse') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
      'Access-Control-Allow-Origin': '*'
    });

    setInterval(() => {
      const data = `data: ${new Date().toISOString()}\n\n`;
      res.write(data);
    }, 1000);
  } else {
    res.writeHead(404);
    res.end();
  }
}).listen(3000);

客户端

html 复制代码
<script>
  const source = new EventSource('http://localhost:3000/sse');
  
  source.onmessage = function(event) {
    console.log('Received:', event.data);
  };
</script>

1.2 ✅ 优点

  • 基于 HTTP,兼容性好、易部署。
  • 简单,浏览器原生支持(无需额外库)。
  • 自动重连(断线重连由浏览器自动处理)。
  • 支持事件命名、ID 等机制。

1.3 ⚠️ 局限性

限制 描述
单向 只能服务器→客户端,不能反向
兼容性 不支持 IE;部分旧版浏览器不完全支持
基于文本 只支持 UTF-8 文本数据,不适合传输二进制
HTTP 限制 不能跨多个域名或端口,受限于 CORS
连接数限制 某些浏览器对同一域名的 EventSource 连接数有限制(通常是 6 个)

1.4 与websocket区别

特性 SSE WebSocket
通信方式 单向(Server → Client) 双向
协议 HTTP(长连接) 自定义协议,基于 TCP
简单性 非常简单 更复杂,需协议升级
二进制支持 不支持 支持
浏览器支持 广泛(除 IE) 更广泛
重连机制 自动 需要手动实现
适合场景 实时日志、通知、股票行情 聊天、游戏、协同编辑

二、基于post请求的 SSE用法

SSE(Server-Sent Events)标准本身不支持 POST 请求作为建立连接的方式 ,它必须通过 HTTP GET 请求 建立连接,因为它要求使用 text/event-stream 持续发送数据,而 HTTP POST 语义上是提交数据并关闭连接,不适合长连接场景。

接口为 POST,响应头是 Content-Type: text/event-stream,前端要消费这个数据流。

这种需求在非标准 SSE的场景中常见,比如:

  • OpenAI Chat Completion Stream 接口
  • 自定义流式返回模型输出
  • 流式日志、下载进度等

服务端要求

响应头设置

http 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

逐块写入数据

js 复制代码
app.post('/your-api-endpoint', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  let i = 0;
  const interval = setInterval(() => {
    i++;
    res.write(`data: ${i}\n\n`);

    if (i >= 5) {
      clearInterval(interval);
      res.end(); // 关闭流
    }
  }, 1000);
});

前端实现

这时前端不再用 EventSource,可以用

  • fetch + ReadableStream
  • XMLHttpRequest + onprogress
  • Axios + onDownloadProgress

2.1 fetch + ReadableStream

js 复制代码
async function postStream() {
  const response = await fetch('/your-api-endpoint', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream'
    },
    body: JSON.stringify({ message: 'hello' })
  });


  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');

  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    // 处理 buffer 中的事件流格式(每条以 \n\n 分隔)
    let lines = buffer.split('\n\n');
    buffer = lines.pop(); // 留下一条不完整的

    for (const chunk of lines) {
      if (chunk.startsWith('data:')) {
        const data = chunk.replace(/^data:\s*/, '');
        console.log('Received:', data);
      }
    }
  }

  console.log('Stream closed');
}

2.2 XMLHttpRequest + onprogress

说明:

在使用 onprogress(或 onDownloadProgress)监听响应流时,responseText 是从连接开始后不断累加的,所以你需要手动做「增量解析」来获取新增数据部分,防止重复处理。

js 复制代码
function xhrSSEWithDelta(url, postData) {
  const xhr = new XMLHttpRequest();
  let lastIndex = 0; // 上一次处理结束的位置

  xhr.open('POST', url);
  xhr.setRequestHeader('Content-Type', 'application/json');
  xhr.setRequestHeader('Accept', 'text/event-stream');

  xhr.onprogress = function () {
    const response = xhr.responseText;

    // 新增部分
    const newPart = response.substring(lastIndex);
    lastIndex = response.length;

    // SSE 按 \n\n 分割事件块
    const events = newPart.split('\n\n');
    for (const event of events) {
      if (event.startsWith('data:')) {
        const data = event.replace(/^data:\s*/, '').trim();
        console.log('Received:', data);
      }
    }
  };

  xhr.send(JSON.stringify(postData));
}

2.3 Axios + onDownloadProgress

核心限制说明

  • onDownloadProgressXHR 的 progress 事件的封装,由 Axios 暴露出来。
  • 依赖浏览器对 responseType: 'text' 的 streaming 支持
  • 只能在浏览器环境中工作(在 Node.js 环境中无效)。
  • 在某些浏览器中(如 Chrome),onDownloadProgress 不会触发 text/event-stream 的每一个 chunk,除非服务端每个 chunk 后强制刷新 buffer

重要提醒:数据 flush 和 buffer 控制 某些服务器(如 Express、Nginx、Flask)会缓冲响应内容 ,导致 onDownloadProgress 只有在数据足够多时才触发。解决方式:

  • 在 Node.js 中调用 res.flush()(需要 compression: false)。
  • 设置 Content-Length: false,避免缓存。
  • 保证写入的数据末尾有 \n\n 之类换行,让 chunk 立刻发送出去。
js 复制代码
import axios from 'axios';

function axiosSSE() {
  let lastText = '';

  axios({
    method: 'post',
    url: 'http://localhost:3000/stream',
    responseType: 'text',
    onDownloadProgress: (progressEvent) => {
      const text = progressEvent.currentTarget.response;
      
      // 增量解析:获取新增部分
      const newText = text.substring(lastText.length);
      lastText = text;

      const events = newText.split('\n\n');
      for (const event of events) {
        if (event.startsWith('data:')) {
          const payload = event.replace(/^data:\s*/, '');
          console.log('Received:', payload);
        }
      }
    }
  });
}

三、实现ai对话框

在使用 fetch + ReadableStream 实现 AI 对话流式输出(比如 ChatGPT 风格对话框)时,必须异步处理增量数据并"及时渲染到页面" ,否则 JS 主线程被阻塞、页面不会刷新。

✅ 问题核心

JS 是单线程的,若你在 while 循环中没有使用 awaityield 控制节奏,UI 是不会刷新的,即使数据已经到了。

✅ 正确做法:异步读取 + 解码 + 渲染 + 控制节奏

📦 fetch + ReadableStream 实现 AI 对话流(完整范例)

js 复制代码
<div id="chat"></div>
<script>
  async function chatStream() {
    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'text/event-stream',
      },
      body: JSON.stringify({ message: '你好' }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let buffer = '';
    const chatDiv = document.getElementById('chat');

    while (true) {
      const { value, done } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      buffer += chunk;

      // 按 SSE 格式处理
      const parts = buffer.split('\n\n');
      buffer = parts.pop(); // 留下不完整的块

      for (const part of parts) {
        const line = part.trim();
        if (line.startsWith('data:')) {
          const data = line.slice(5).trim();

          if (data === '[DONE]') return;

          // 🔄 增量渲染
          appendText(chatDiv, data);

          // 🔁 让出主线程,保证页面可响应
          await sleep(0); // 控制节奏
        }
      }
    }
  }

  function appendText(el, text) {
    el.textContent += text;
  }

  function sleep(ms) {
    return new Promise(r => setTimeout(r, ms));
  }

  chatStream();
</script>

✅ 为什么需要 await sleep(0)

这行代码虽然"啥都不等",但它允许浏览器在下一帧刷新 UI,也叫"让出主线程"。

否则你可能会遇到:

  • 文本一大段卡着最后才刷出来。
  • JS 占满 CPU,用户交互卡顿。

3.1 React 实现一个可打字效果的组件

功能特点:

  • 🚀 支持 fetch 请求 SSE 流(例如 OpenAI、FastAPI、Node 流等)
  • 🪄 打字机式一字一字显示
  • 🧠 自动换行、自动滚动
  • ✨ 支持 loading 状态控制
js 复制代码
import React, { useState, useRef, useEffect } from 'react';

const ChatStreamTyping = ({ endpoint, prompt }) => {
  const [content, setContent] = useState('');
  const [loading, setLoading] = useState(false);
  const containerRef = useRef(null);

  useEffect(() => {
    if (prompt) {
      streamAIResponse(prompt);
    }
  }, [prompt]);

  const streamAIResponse = async (message) => {
    setLoading(true);
    setContent('');

    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'text/event-stream',
      },
      body: JSON.stringify({ message }),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');

    let buffer = '';
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });

      const parts = buffer.split('\n\n');
      buffer = parts.pop(); // 剩余部分留待下次

      for (const part of parts) {
        if (part.startsWith('data:')) {
          const rawData = part.replace(/^data:\s*/, '').trim();

          if (rawData === '[DONE]') {
            setLoading(false);
            return;
          }

          // 打字机效果:逐字添加
          await appendCharByChar(rawData);
        }
      }
    }

    setLoading(false);
  };

  const appendCharByChar = async (text) => {
    for (const char of text) {
      setContent(prev => prev + char);
      scrollToBottom();
      await new Promise(res => setTimeout(res, 20)); // 打字速度控制
    }
  };

  const scrollToBottom = () => {
    requestAnimationFrame(() => {
      if (containerRef.current) {
        containerRef.current.scrollTop = containerRef.current.scrollHeight;
      }
    });
  };

  return (
    <div className="border rounded-lg p-4 bg-gray-100 max-w-lg h-60 overflow-auto" ref={containerRef}>
      <pre className="whitespace-pre-wrap font-mono text-gray-800">{content}</pre>
      {loading && <div className="mt-2 text-sm text-gray-500 animate-pulse">AI 正在思考中...</div>}
    </div>
  );
};

export default ChatStreamTyping;

3.2 vue 实现一个可打字效果的组件

功能特点:

  • 🚀 支持后端 SSE 接口流式返回内容
  • 🧠 打字效果一字一字地显示
  • ⏳ 支持 loading 状态提示
  • 🔽 自动滚动到对话底部

在这些基础上,扩展以下:

功能 实现方式
用户输入框 添加 <input v-model="input">
支持多轮对话 使用对话数组 <ul><li v-for="msg in messages">...</li></ul>
Markdown 支持 marked.jsvue3-markdown-it
停止按钮 AbortController 控制 fetch 中断
js 复制代码
<template>
  <div class="chat-box" ref="chatBox">
    <ul>
      <li v-for="(msg, idx) in messages" :key="idx" :class="msg.role">
        <strong>{{ msg.role === 'user' ? '🧑‍💻 你:' : '🤖 AI:' }}</strong>
        <markdown-it v-if="msg.role === 'assistant'" :source="msg.content" />
        <div v-else class="msg">{{ msg.content }}</div>
      </li>
    </ul>

    <div class="input-area">
      <input
        v-model="input"
        @keydown.enter="send"
        placeholder="输入你的问题..."
      />
      <button @click="send" :disabled="loading">发送</button>
      <button @click="stop" v-if="loading">停止</button>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, nextTick } from 'vue'
import MarkdownIt from 'vue3-markdown-it'

const input = ref('')
const messages = reactive([])
const loading = ref(false)
const chatBox = ref(null)

let controller = null

const scrollToBottom = () => {
  nextTick(() => {
    chatBox.value.scrollTop = chatBox.value.scrollHeight
  })
}

const send = async () => {
  const message = input.value.trim()
  if (!message || loading.value) return

  messages.push({ role: 'user', content: message })
  messages.push({ role: 'assistant', content: '' }) // 预占位显示 AI 回复
  input.value = ''
  scrollToBottom()

  controller = new AbortController()
  loading.value = true

  try {
    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'text/event-stream',
      },
      body: JSON.stringify({ message }),
      signal: controller.signal
    })

    const reader = res.body.getReader()
    const decoder = new TextDecoder('utf-8')
    let buffer = ''

    while (true) {
      const { value, done } = await reader.read()
      if (done) break

      buffer += decoder.decode(value, { stream: true })
      const parts = buffer.split('\n\n')
      buffer = parts.pop()

      for (const part of parts) {
        if (part.startsWith('data:')) {
          const text = part.replace(/^data:\s*/, '').trim()
          if (text === '[DONE]') {
            loading.value = false
            return
          }

          const assistant = messages.findLast(m => m.role === 'assistant')
          assistant.content += text
          scrollToBottom()
          await new Promise(r => setTimeout(r, 10)) // 打字效果节奏
        }
      }
    }

    loading.value = false
  } catch (err) {
    console.error('请求中断或失败', err)
    loading.value = false
  }
}

const stop = () => {
  controller?.abort()
  loading.value = false
}
</script>

<style scoped>
.chat-box {
  max-height: 500px;
  overflow-y: auto;
  padding: 1rem;
  border: 1px solid #ccc;
  background: #f9f9f9;
  border-radius: 8px;
}
ul {
  list-style: none;
  padding: 0;
}
li {
  margin-bottom: 1em;
}
.user .msg {
  color: #333;
}
.assistant

欢迎关注我的前端自检清单,我和你一起成长

相关推荐
临界点oc9 天前
SpringAI + DeepSeek大模型应用开发 - 进阶篇(上)
openai·springai·阿里百炼
伊泽瑞尔9 天前
打造极致聊天体验:uz-chat——全端AI聊天组件来了!
后端·chatgpt·openai
量子位10 天前
OpenAI 硬件陷 “抄袭门”,商标 / 设计极其相似,官方火速删帖
openai
新智元10 天前
任务太难,连 ChatGPT 都弃了!最强 AI 神器一键拆解,首测来袭
人工智能·openai
新智元10 天前
特斯拉 Robotaxi 首秀翻车!逆行急刹吓哭网友,半路抛客全程高能预警
人工智能·openai
马腾化云东10 天前
从 OpenAPI 到 AI 助手:我开发了一个让 API 文档"活"起来的工具
openai·ai编程·mcp
程序员Better10 天前
收藏警告-2025年当前主流AI工具网站的详细总结
openai·ai编程·deepseek
黑黑的脸蛋10 天前
SSE(server sent events)流式数据传递
aigc·openai
新智元11 天前
CS 博士求职 8 个月 0 offer,绝望转行!斯坦福入学停滞,全美仅增 0.2%
人工智能·openai·jetbrains