SSE 流式输出完全指南 — 从原理到实战

0 阅读10分钟

SSE 流式输出完全指南 — 从原理到实战

动画.gif

目录

  1. 一、SSE 是什么?为什么要用它
  2. 二、SSE vs WebSocket:如何选择
  3. 三、项目架构总览
  4. 四、服务端核心实现(重点)
  5. 五、客户端核心实现(重点)
  6. 六、七大核心技术要点深度解析
  7. 七、踩坑记录与解决方案
  8. 八、总结与延伸

一、SSE 是什么?为什么要用它

Server-Sent Events (SSE) 是一种基于 HTTP 协议的服务器推送技术。它允许服务器主动向客户端持续发送数据,而无需客户端反复轮询。

一句话理解: 普通 HTTP 是「一问一答」的模式,而 SSE 让服务器可以「滔滔不绝」地持续向客户端输出内容——这正是 ChatGPT 等大模型打字机效果的核心技术。

SSE 的三大核心特点

特点说明
单向通信只能从服务器 → 客户端推送数据,不需要双向通道
基于标准 HTTP无需特殊协议或 WebSocket 握手,天然支持代理和防火墙
自动重连浏览器原生支持断线重连,无需手写重连逻辑
纯文本格式传输格式为 data: 内容\n\n,简单直观

二、SSE vs WebSocket:如何选择

很多开发者会困惑:「既然有 WebSocket,为什么还要用 SSE?」其实它们面向的场景不同:

对比维度SSEWebSocket
通信方向单向(服务端→客户端)双向(全双工)
协议基础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 再继续下一次循环
    }
}

工作流程:

  1. function* 表示这是一个生成器函数,可以用 yield 分段返回值
  2. async 让我们能在内部使用 await,控制每次 yield 的间隔时间
  3. 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 都是最合适的技术选择。希望这篇教程能帮助你真正理解并掌握它!