一、介绍
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
核心限制说明
onDownloadProgress
是 XHR 的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
循环中没有使用await
、yield
控制节奏,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.js 或 vue3-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
欢迎关注我的前端自检清单,我和你一起成长