SSE 流式输出完全指南 — 从原理到实战
目录
- 一、SSE 是什么?为什么要用它
- 二、SSE vs WebSocket:如何选择
- 三、项目架构总览
- 四、服务端核心实现(重点)
- 五、客户端核心实现(重点)
- 六、七大核心技术要点深度解析
- 七、踩坑记录与解决方案
- 八、总结与延伸
一、SSE 是什么?为什么要用它
Server-Sent Events (SSE) 是一种基于 HTTP 协议的服务器推送技术。它允许服务器主动向客户端持续发送数据,而无需客户端反复轮询。
一句话理解: 普通 HTTP 是「一问一答」的模式,而 SSE 让服务器可以「滔滔不绝」地持续向客户端输出内容——这正是 ChatGPT 等大模型打字机效果的核心技术。
SSE 的三大核心特点
| 特点 | 说明 |
|---|---|
| 单向通信 | 只能从服务器 → 客户端推送数据,不需要双向通道 |
| 基于标准 HTTP | 无需特殊协议或 WebSocket 握手,天然支持代理和防火墙 |
| 自动重连 | 浏览器原生支持断线重连,无需手写重连逻辑 |
| 纯文本格式 | 传输格式为 data: 内容\n\n,简单直观 |
二、SSE vs WebSocket:如何选择
很多开发者会困惑:「既然有 WebSocket,为什么还要用 SSE?」其实它们面向的场景不同:
| 对比维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务端→客户端) | 双向(全双工) |
| 协议基础 | HTTP / HTTP/2 | 独立的 WS/WSS 协议 |
| 自动重连 | ✅ 浏览器内置 | ❌ 需要自己实现 |
| 实现复杂度 | 极低(几十行代码) | 中等 |
| 适用场景 | AI 流式响应 / 股票行情 / 日志流 | 在线聊天 / 多人协作 / 游戏 |
| 代理兼容性 | ✅ 完美通过所有代理 | ⚠️ 可能被 Nginx/防火墙拦截 |
💡 选型建议: 如果只是服务端单向推送数据(如 AI 回复、实时通知),优先用 SSE;如果需要频繁的双向交互(聊天室、协同编辑),再用 WebSocket。
三、项目架构总览
本文以一个真实可运行的项目案例来讲解 SSE 的完整实现。该项目实现了类似 ChatGPT 的流式 Markdown 输出效果:
┌─────────────────────────────────────────────────────┐
│ 浏览器 (Client) │
│ │
│ ┌───────────────────────────┐ │
│ │ Vue 3 + EventSource │ │
│ │ (接收流式数据 + 渲染) │ │
│ └───────────┬───────────────┘ │
│ │ /api/getReqText?offset=xxx │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Vite Proxy │ 转发到 localhost:3000 │
│ └───────┬──────────┘ │
└───────────┼─────────────────────────────────────────┘
│ HTTP Request
▼
┌─────────────────────────────────────────────────────┐
│ Node.js + Express :3000 │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ async function* │←─│ chatText.js │ │
│ │ 异步生成器 │ │ (Markdown 文本) │ │
│ └───────┬──────────┘ └──────────────────┘ │
│ │ yield chunk (逐字符, 20ms间隔) │
│ ▼ │
│ res.write(`data: ${chunk}\n\n`) │
│ → SSE text/event-stream 推送 │
└─────────────────────────────────────────────────────┘
项目目录结构
chatAITest/
├── serve/ # 后端服务 (Express)
│ ├── index.js # ★ 主入口:SSE 接口 + 异步生成器
│ ├── chatText.js # 数据源:待输出的 Markdown 文本
│ ├── package.json # { "express": "^5.2.1" }
│ └── package-lock.json
└── client/
└── chat-client/ # 前端应用 (Vue 3 + Vite)
├── vite.config.js # ★ Vite 配置(含代理)
├── package.json # { "vue": "^3.5.32", "marked": "^18.0.0" }
├── index.html
└── src/
├── main.js
├── App.vue
├── style.css
└── components/
└── HelloWorld.vue # ★ 核心组件:SSE 接收 + Markdown渲染 + 智能滚动
数据流转全过程
用户点击「开始」
→ connectEvent() 创建 EventSource,传入 offset=message.length(断点续传)
→ GET /api/getReqText?offset=xxx
→ Vite Proxy 拦截,去掉 /api 前缀,转发到 localhost:3000/getReqText
→ Express 设置 text/event-stream 响应头
→ 异步生成器从 offset 位置逐字符 yield(每字符 20ms)
→ res.write(`data: 字符\n\n`) 以 SSE 格式推送
→ 浏览器 EventSource.onmessage 触发
→ message.value += data(拼接原始文本)
→ marked.parse(message.value) 解析为 HTML(全量重新解析)
→ renderedHtml 更新 → watch 触发 → scrollToBottom()
→ 智能滚动:判断 autoScrollEnabled 状态决定是否跟随
四、服务端实现(重点)
4.1 完整代码
服务端使用 Express 5 + Node.js 异步生成器 (async function*) 来实现流式输出。这是整个项目的灵魂所在:
serve/index.js
const express = require("express");
const { sseText, tcmText } = require("./chatText");
const app = express();
const port = 3000;
app.listen(port, () => {
console.log(`express 服务启动,端口为 ${port}`);
});
/**
* ★ 核心:异步生成器 —— 逐字产出内容
*
* 支持断点续传:通过 offset 参数指定起始位置,
* 用户中途断开后重联可以从上次的位置继续输出。
*/
async function* generateMarkdownContent(offset = 0) {
const sections = tcmText;
// chunkSize=1 表示每次只 yield 1 个字符
// 配合 20ms 延迟,产生流畅的打字机效果
const chunkSize = 1;
for (let i = offset; i < sections.length; i += chunkSize) {
const chunk = sections.slice(i, i + chunkSize);
await new Promise(resolve => setTimeout(resolve, 20));
yield chunk; // ← 关键!每次产出一个字符后暂停
}
}
// ★ SSE 接口路由
app.get("/getReqText", async (req, res) => {
// 断点续传:读取客户端传入的偏移量(已接收的字符数)
const offset = parseInt(req.query.offset || '0', 10);
// 设置 SSE 必需的响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream', // 声明 SSE 格式
'Cache-Control': 'no-cache', // 禁用缓存
'Connection': 'keep-alive', // 保持长连接
'Access-Control-Allow-Origin': '*', // 允许跨域
});
// 发送初始注释(保持连接活跃,不触发任何事件回调)
res.write(': connected\n\n');
try {
// ★ for await...of 遍历异步生成器,每 yield 一个值就立即推送给客户端
for await (const chunk of generateMarkdownContent(offset)) {
// 换行符需按 SSE 规范转义(换行符会破坏 SSE 数据帧边界)
const safeChunk = chunk.replace(/\n/g, '\ndata: ');
res.write(`data: ${safeChunk}\n\n`);
}
// 发送结束标记,前端据此判断传输完成
res.write(`data: [DONE]\n\n`);
res.end();
} catch (error) {
console.error('流式生成错误:', error);
res.write(`data: 发生错误: ${error.message}\n\n`);
res.end();
}
});
serve/chatText.js(数据源)
// 存储两篇待输出的长文 Markdown 文本
const sseText = `# Server-Sent Events (SSE) 完全指南\n\n...`;
const tcmText = `# 中医基础理论体系\n\n...`;
module.exports = {
sseText,
tcmText
};
4.2 三大关键技术点解析
① 异步生成器 async function* — 流式输出的引擎
这是整段代码中最关键的部分。普通函数执行完就结束了,但生成器可以在执行过程中暂停并多次返回值:
async function* generateMarkdownContent() {
for (let i = 0; i < text.length; i++) {
yield text[i]; // 暂停在这里,把值交出去
await sleep(20); // 等 20ms 再继续下一次循环
}
}
工作流程:
function*表示这是一个生成器函数,可以用yield分段返回值async让我们能在内部使用await,控制每次yield的间隔时间for await...of在调用方遍历生成器时,每拿到一个yield的值就立即处理
调节打字速度的两个参数:
const chunkSize = 1; // 每次 yield 的字符数(越小越细腻,1 = 逐字)
const delay = 20; // 每次延迟毫秒数(越大越慢)
💡 为什么不用
setInterval? 因为生成器的语义更清晰——它就是一个「可迭代的数据流」,配合for await...of可以优雅地逐条消费。而且生成器天然支持断点续传(只需传入 offset 参数即可跳到任意位置继续)。
② SSE 响应头 — 告诉浏览器「这是一条长连接」
| 响应头 | 作用 | 必填? |
|---|---|---|
Content-Type: text/event-stream | 声明这是 SSE 格式的响应 | 必须 |
Cache-Control: no-cache | 禁用缓存,确保数据实时送达 | 必须 |
Connection: keep-alive | 保持长连接不断开 | 必须 |
Access-Control-Allow-Origin: * | 允许跨域访问 | 按需配置 |
③ SSE 数据格式规范
SSE 有严格的格式要求,每个消息以 \n\n 结尾:
# ✅ 正确格式:data 字段 + 双换行结束
data: 你好\n\n
data: 世界\n\n
# ❌ 错误格式:缺少结尾的 \n\n,浏览器无法正确解析
data: 你好
data: 世界
# ✅ 自定义事件类型
event: custom-event
data: {"msg": "hello"}\n\n
# ✅ 带事件 ID(用于断点续传和重连恢复)
id: msg_001
data: 重要消息\n\n
# 💡 注释格式(以 : 开头):不触发任何回调,常用于心跳保活
: heartbeat ping\n\n
五、客户端实现(重点)
5.1 Vue 3 组件完整代码
客户端使用 EventSource API 接收流式数据,并用 marked 库实时解析 Markdown。同时实现了智能滚动控制——用户向上查看历史内容时暂停跟随,滚回底部时恢复。
client/chat-client/src/components/HelloWorld.vue
<template>
<div class="chat-container">
<div class="chat-header">
<h5>SSE 流式输出演示</h5>
</div>
<!-- 绑定 @scroll 事件监听滚动行为 -->
<div class="chat-messages" ref="chatMessages" @scroll="handleScroll">
<div class="message-content" v-html="renderedHtml"></div>
</div>
<div class="btns">
<button @click="connectEvent">{{ chatLoading ? '正在加载...' : '开始'}}</button>
<button @click="stopEvent">结束</button>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, watch } from "vue";
import { marked } from "marked";
const message = ref(""); // 原始文本累积
const renderedHtml = ref(""); // 解析后的 HTML
let eventSource = null;
const chatMessages = ref(null);
const chatLoading = ref(false);
const autoScrollEnabled = ref(true); // ★ 是否处于自动跟随滚动模式
// ==================== 智能滚动系统 ====================
// 滚动到底部(仅在自动跟随模式生效)
const scrollToBottom = () => {
if (!autoScrollEnabled.value) return; // 用户往上滚了,不强制滚动
nextTick(() => {
const el = chatMessages.value;
if (el) {
_isProgramScroll = true; // 标记为程序驱动滚动
el.scrollTop = el.scrollHeight;
}
});
};
// 区分「用户手动滚动」vs「程序驱动滚动」的关键机制
let _isProgramScroll = false; // 标记当前是否为程序触发的滚动
let _lastScrollTop = 0; // 记录上次滚动位置用于判断方向
const handleScroll = () => {
const el = chatMessages.value;
if (!el || _isProgramScroll) return; // 程序驱动的滚动,忽略处理
const direction = el.scrollTop - _lastScrollTop;
_lastScrollTop = el.scrollTop;
// 用户向上滚动 → 关闭自动跟随(锁定在当前位置方便阅读)
if (direction < 0) {
autoScrollEnabled.value = false;
}
// 用户滚到接近底部(容差 10px)→ 恢复自动跟随
const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceToBottom <= 10) {
autoScrollEnabled.value = true;
}
};
// 程序滚动结束后清除标记(requestAnimationFrame 确保在下一帧之后才清除)
const _clearProgramFlag = () => {
requestAnimationFrame(() => {
_isProgramScroll = false;
});
};
// 监听 HTML 内容变化,统一触发滚动
watch(renderedHtml, () => {
scrollToBottom();
_clearProgramFlag();
});
// ==================== SSE 连接管理 ====================
// 创建连接(支持断点续传)
const connectEvent = () => {
if (chatLoading.value) return;
// ★ 断点续传:不清空已有内容,将当前长度作为 offset 传给服务端
const offset = message.value.length;
eventSource = new EventSource(`/api/getReqText?offset=${offset}`);
eventSource.onmessage = (event) => {
chatLoading.value = true;
const data = event.data;
if (data === "[DONE]") {
chatLoading.value = false;
eventSource.close();
console.warn("流传输完成。");
return;
}
// 拼接原始文本 → marked 全量重新解析为 HTML
// watch(renderedHtml) 会自动触发 scrollToBottom()
message.value += data;
renderedHtml.value = marked.parse(message.value);
};
};
// 手动停止连接(保留已接收的内容)
const stopEvent = () => {
if (eventSource) {
chatLoading.value = false;
eventSource.close();
eventSource = null;
}
};
</script>
5.2 Vite 开发代理配置
由于前后端分别运行在不同端口(Vite 默认 5173,Express 3000),需要在 Vite 中配置代理解决跨域问题:
client/chat-client/vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, "") // 去掉 /api 前缀再转发
}
}
}
})
代理转发过程:
浏览器请求: GET /api/getReqText?offset=100
↓
Vite Dev Server (localhost:5173) 匹配 /api 规则
↓
去掉 /api 前缀 → 转发到 target
↓
Express 收到: GET /getReqText?offset=100 (无跨域问题)
📌 这样前端就不存在跨域问题了,开发体验和生产部署一致。
5.3 关键 CSS 布局(确保滚动区域正常工作)
/* Flexbox 三段式布局:头部固定 + 中间自适应滚动 + 底部固定 */
.chat-container {
width: 100vw;
height: 100vh; /* 必须有固定高度 */
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header { flex-shrink: 0; } /* 头部固定高度 */
.chat-messages {
flex: 1; /* 占据剩余全部空间 */
overflow-y: auto; /* ★ 核心!缺少此项则无法滚动 */
}
.btns { flex-shrink: 0; } /* 底部按钮区固定 */
5.4 依赖清单
服务端 package.json:
{
"dependencies": {
"express": "^5.2.1"
}
}
前端 package.json:
{
"dependencies": {
"marked": "^18.0.0", // Markdown 解析库
"vue": "^3.5.32" // Vue 3 框架
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"vite": "^8.0.4"
}
}
六、七大核心技术要点深度解析
要点 1:async function* 异步生成器 — 打字机效果的本质
生成器是 SSE 流式输出的最佳搭档。它的本质是一个「可以被暂停和恢复的函数」,每次 yield 就像是在说:「这里有一批数据,你先拿去用,我休息一下再继续生产」。
// 生成器的执行模型:
//
// 调用方 for await...of ←→ 生成器函数内部
// ─────────────────────────────────────────────
// 拿到第1个 yield 的值 "中" yield "中" ← 暂停
// 拿到第2个 yield 的值 "医" yield "医" ← 暂停
// 拿到第3个 yield 的值 "基" yield "基" ← 暂停
// ...循环直到文本结束 return
对比传统方案的优势:
| 方案 | 代码量 | 可读性 | 断点续传 | 内存占用 |
|---|---|---|---|---|
| setInterval + 闭包变量 | 多 | 差 | 难以实现 | 高(需维护状态) |
| 异步生成器 | 少 | 优 | 天然支持(offset 参数) | 低 |
要点 2:for await...of — 优雅的消费方式
在 Express 路由中使用 for await...of 遍历异步生成器,每 yield 一个值就立即通过 res.write() 推送给客户端:
for await (const chunk of generateMarkdownContent(offset)) {
res.write(`data: ${chunk}\n\n`);
}
// 循环结束后执行 res.write('data: [DONE]') 和 res.end()
这比 setInterval + 闭包计数器 的写法更声明式、更容易理解。
要点 3:断点续传(Resume from Offset)— 重联不丢失进度
这是本项目的一个实用创新点。用户中途点击「结束」后再次点击「开始」,内容会从上次断开的位置继续,而不是从头开始:
// 客户端:将当前已接收的字符数作为 offset 传给服务端
const offset = message.value.length;
eventSource = new EventSource(`/api/getReqText?offset=${offset}`);
// 服务端:从 offset 位置开始生成
async function* generateMarkdownContent(offset = 0) {
for (let i = offset; i < text.length; i++) { ... }
}
工作原理:
首次连接: offset = 0 → 从头输出 "中医基础理论体系..."
用户看到500字符后点「结束」 → message.length = 500
再次点「开始」 → offset = 500 → 服务端从第501个字符继续输出
💡 这比 SSE 标准的
Last-Event-ID更灵活——我们可以用任意语义的偏移量(字符位置、段落索引等),而不限于整数序列 ID。
要点 4:实时 Markdown 渲染 — marked.parse()
每收到一个字符就用 marked.parse() 重新解析完整的累积文本。即使是不完整的 Markdown 片段(比如只有 # 标题还没写完),也能正确渲染出当前状态:
message.value += data; // 追加原始字符
renderedHtml.value = marked.parse(message.value); // 全量重新解析为 HTML
为什么是全量重解析而非增量解析? 因为 Markdown 的语法依赖上下文(比如 ### 需要知道前面有没有未闭合的标记),增量解析非常复杂且容易出错。对于打字机场景,全量重解析的性能完全够用。
为什么用 requestAnimationFrame 清除程序滚动标记?
const _clearProgramFlag = () => {
requestAnimationFrame(() => {
_isProgramScroll = false; // 延迟到下一帧才清除
});
};
因为 scrollTop = scrollHeight 赋值后,浏览器可能在同一帧或下一帧才触发 scroll 事件。如果立即清除标记,scroll 事件中的判断就会失效。用 requestAnimationFrame 确保本轮所有同步 scroll 事件都处理完毕后再恢复正常检测。
要点 5:watch + nextTick — 高频更新下的稳定渲染方案
SSE 每 20ms 推送一次数据(每秒约 50 次),属于高频更新场景。采用以下组合保证稳定性:
// 用 watch 替代 onmessage 中直接调用,统一管理滚动触发时机
watch(renderedHtml, () => {
scrollToBottom(); // 通过 nextTick 确保 DOM 已更新
_clearProgramFlag(); // 清除程序滚动标记
});
// scrollToBottom 内部使用 nextTick 等待 DOM 更新完成
const scrollToBottom = () => {
if (!autoScrollEnabled.value) return;
nextTick(() => {
el.scrollTop = el.scrollHeight; // 即时赋值,不用 smooth 动画
});
};
为什么不用 behavior: 'smooth'? 因为平滑动画持续时间通常 200-300ms,而数据每 20ms 就来一次。上一次动画还没结束就被新的覆盖了,导致视觉上看起来像「卡住了不动」。即时赋值虽然不够丝滑,但在高频场景下是最可靠的方案。
七、踩坑记录与解决方案
在开发过程中遇到了几个典型问题,这里一一记录供参考:
坑 1:重联后内容从头开始
- 现象: 断开后重新连接,之前收到的内容丢失了。
- 原因: 每次连接都清空
message,且服务端总是从头开始生成。 - 解决:
- 客户端:不清空已有内容,将
message.length作为offset传给服务端 - 服务端:根据
req.query.offset从指定位置继续生成
- 客户端:不清空已有内容,将
八、总结与延伸
回顾整个项目,SSE 流式输出的技术栈简洁而高效:
| 环节 | 技术 | 核心作用 |
|---|---|---|
| 数据源 | JavaScript 模板字符串 | 存储待输出的 Markdown 文本(两篇长文) |
| 服务端 | Express 5 + async generator | 逐字符分块(20ms间隔)+ SSE 格式推送 |
| 传输协议 | SSE (HTTP + text/event-stream) | 单向长连接,持续推送数据 |
| 客户端 | EventSource API + Vue 3 | 接收流式数据 + 响应式状态管理 |
| Markdown 渲染 | marked (^18.0.0) | 全量实时解析为 HTML |
| 智能滚动 | 方向检测 + rAF 标记 | 向上滚锁定 / 回到底部恢复跟随 |
| 开发工具 | Vite 8 + 代理 | 解决前后端跨域,HMR 热更新 |
💡 最后的话: SSE 虽然简单,但它完美解决了「服务端单向推送」这一大类需求。从 ChatGPT 的打字机效果,到股票行情的实时跳动,再到日志系统的 tail -f 式输出,SSE 都是最合适的技术选择。希望这篇教程能帮助你真正理解并掌握它!