前端SSE实战指南

前端 SSE 实战指南:从 EventSource 到 Fetch Streams

Server-Sent Events (SSE) 是一种基于 HTTP 的服务端推送技术,允许服务器通过持久化的 HTTP 连接向客户端单向推送数据。相比 WebSocket 的全双工通信,SSE 更轻量、更简单,特别适合"服务端推送、客户端接收"的场景,如实时通知、AI 流式输出、进度条更新等。

本文将从实际开发出发,根据接口的 HTTP 方法(GET / POST)分别讲解最合适的 SSE 前端实现方案,并提供可直接运行的代码示例。


目录

  • [一、SSE 协议基础](#一、SSE 协议基础 "#%E4%B8%80sse-%E5%8D%8F%E8%AE%AE%E5%9F%BA%E7%A1%80")
  • [二、GET 接口 → EventSource:为什么它是天然之选](#二、GET 接口 → EventSource:为什么它是天然之选 "#%E4%BA%8Cget-%E6%8E%A5%E5%8F%A3--eventsource%E4%B8%BA%E4%BB%80%E4%B9%88%E5%AE%83%E6%98%AF%E5%A4%A9%E7%84%B6%E4%B9%8B%E9%80%89")
  • [三、POST 接口 → fetch + ReadableStream:突破 EventSource 的限制](#三、POST 接口 → fetch + ReadableStream:突破 EventSource 的限制 "#%E4%B8%89post-%E6%8E%A5%E5%8F%A3--fetch--readablestream%E7%AA%81%E7%A0%B4-eventsource-%E7%9A%84%E9%99%90%E5%88%B6")
  • 四、方案对比与选型建议
  • 五、常见陷阱与最佳实践
  • [六、封装一个通用的 SSE 客户端](#六、封装一个通用的 SSE 客户端 "#%E5%85%AD%E5%B0%81%E8%A3%85%E4%B8%80%E4%B8%AA%E9%80%9A%E7%94%A8%E7%9A%84-sse-%E5%AE%A2%E6%88%B7%E7%AB%AF")

一、SSE 协议基础

SSE 的核心是 text/event-stream 这一 MIME 类型。服务端响应的格式如下:

vbnet 复制代码
data: 第一条消息\n
\n
data: 第二条消息\n
\n
event: progress\ndata: 50%\n\n
event: result\ndata: {"answer": "你好"}\n\n

关键规则:

  • 每条消息由 field: value\n 组成,消息之间用空行 \n 分隔
  • 支持的字段:data(数据体)、event(事件类型)、id(消息 ID)、retry(重连间隔)
  • data 可以多行,多行 data 之间用 \n 拼接
  • 连接断开后,浏览器会自动重连(这是 SSE 的重要特性)

二、GET 接口 → EventSource:为什么它是天然之选

2.1 EventSource 是什么

EventSource 是浏览器原生提供的 SSE API,属于 HTML5 标准的一部分。它的设计目标就是消费 text/event-stream 格式的响应流。

2.2 为什么 GET + EventSource 是最佳组合

论点 说明
协议匹配 SSE 标准本身就基于 GET 语义设计------客户端发起一个长连接请求,服务端持续推送数据。EventSource 内部就是发一个 GET 请求
自动重连 EventSource 内置断线重连机制,连接中断后会自动按 retry 间隔或默认 3 秒重连,且自动携带 Last-Event-ID 请求头,服务端可据此续传
API 简洁 只需 new EventSource(url) 即可建立连接,无需手动处理流解析、换行分割、事件分发等底层逻辑
浏览器兼容 所有现代浏览器均支持(IE 除外,但 IE 已停止支持)
语义正确 GET 请求是幂等、安全的,适合"获取数据"的语义;SSE 本质上就是"获取一个持续更新的数据流"

2.3 完整示例

基础用法
javascript 复制代码
const evtSource = new EventSource('/api/notifications');

// 监听默认的 message 事件
evtSource.onmessage = (event) => {
  console.log('收到消息:', event.data);
};

// 监听自定义事件类型
evtSource.addEventListener('progress', (event) => {
  const percent = event.data;
  console.log(`进度: ${percent}%`);
});

evtSource.addEventListener('result', (event) => {
  const data = JSON.parse(event.data);
  console.log('最终结果:', data);
  // 收到最终结果后关闭连接
  evtSource.close();
});

// 连接错误处理
evtSource.onerror = (event) => {
  if (evtSource.readyState === EventSource.CONNECTING) {
    console.log('连接断开,正在自动重连...');
  } else if (evtSource.readyState === EventSource.CLOSED) {
    console.log('连接已关闭');
  }
};
带 URL 参数的 GET 请求
javascript 复制代码
// 通过 URL 查询参数传递数据
const params = new URLSearchParams({
  topic: 'weather',
  city: 'shenzhen'
});

const evtSource = new EventSource(`/api/stream?${params.toString()}`);

evtSource.addEventListener('weather-update', (event) => {
  const weather = JSON.parse(event.data);
  updateWeatherUI(weather);
});
手动控制连接生命周期
javascript 复制代码
class SSEClient {
  constructor(url) {
    this.url = url;
    this.eventSource = null;
  }

  connect() {
    this.eventSource = new EventSource(this.url);

    this.eventSource.onopen = () => {
      console.log('SSE 连接已建立');
    };

    this.eventSource.onmessage = (event) => {
      // 解析 JSON 数据
      try {
        const data = JSON.parse(event.data);
        this.onReceiveData(data);
      } catch {
        this.onReceiveData(event.data);
      }
    };

    this.eventSource.onerror = () => {
      console.log('SSE 连接出错,浏览器将自动重连');
    };
  }

  // 子类重写此方法处理数据
  onReceiveData(data) {
    console.log('data:', data);
  }

  disconnect() {
    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
      console.log('SSE 连接已主动关闭');
    }
  }
}

// 使用
const client = new SSEClient('/api/live-updates');
client.onReceiveData = (data) => {
  document.getElementById('output').textContent = data.text;
};
client.connect();

// 不再需要时关闭
// client.disconnect();

2.4 EventSource 的局限性

局限 影响
仅支持 GET 无法发送 POST/PUT/DELETE 请求,无法携带请求体
无法自定义请求头 无法设置 AuthorizationContent-Type 等请求头(除非通过 URL 参数传递 token,但不安全)
无法控制请求体 无法发送 JSON body
错误处理有限 只能知道连接出错,无法获取 HTTP 状态码等详细信息

正是这些局限性,使得当接口为 POST 方法时,我们需要 fetch + ReadableStream 方案。


三、POST 接口 → fetch + ReadableStream:突破 EventSource 的限制

3.1 为什么需要 POST

在实际开发中,POST 接口的 SSE 场景非常常见:

  • AI 对话接口:需要将用户的 prompt 作为 JSON body 发送,服务端流式返回生成内容
  • 复杂查询:筛选条件太多,URL 参数放不下或不够安全
  • 需要鉴权 :请求头中需要携带 Authorization: Bearer xxx
  • 批量操作进度:需要发送操作 ID 或参数,服务端推送进度

3.2 fetch + ReadableStream 方案原理

sequenceDiagram participant Browser as 浏览器 participant Server as 服务端 Browser->>Server: POST /api/chat<br/>Content-Type: application/json<br/>Authorization: Bearer xxx<br/>Body: {&#34;prompt&#34;: &#34;hello&#34;} Server-->>Browser: 200 OK<br/>Content-Type: text/event-stream Server-xBrowser: data: {&#34;chunk&#34;: &#34;你&#34;} Server-xBrowser: data: {&#34;chunk&#34;: &#34;好&#34;} Server-xBrowser: data: [DONE] Note over Browser: fetch 读取 response.body<br/>通过 getReader() 逐块读取<br/>手动解析 SSE 格式数据

核心思路:使用 fetch 发送 POST 请求,然后通过 response.body.getReader() 逐块读取响应流,手动解析 SSE 格式的数据。

3.3 完整示例

基础版:流式读取 AI 对话
javascript 复制代码
async function chatStream(prompt) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer your-token-here'
    },
    body: JSON.stringify({ prompt })
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = ''; // 用于处理不完整的消息块

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

    // 将 Uint8Array 解码为字符串,拼接到缓冲区
    buffer += decoder.decode(value, { stream: true });

    // 按双换行分割消息(SSE 协议标准)
    const messages = buffer.split('\n\n');
    // 最后一段可能不完整,保留在 buffer 中
    buffer = messages.pop();

    for (const msg of messages) {
      if (!msg.trim()) continue;

      // 解析每行字段
      let data = '';
      let eventType = '';
      for (const line of msg.split('\n')) {
        if (line.startsWith('data:')) {
          data += line.slice(5).trimStart();
        } else if (line.startsWith('event:')) {
          eventType = line.slice(6).trim();
        }
      }

      if (data === '[DONE]') {
        console.log('流结束');
        return;
      }

      if (data) {
        try {
          const parsed = JSON.parse(data);
          process.stdout.write(parsed.chunk || data);
        } catch {
          console.log(data);
        }
      }
    }
  }
}

// 使用
chatStream('请用中文写一首关于代码的诗');
进阶版:完整 SSE 解析器 + AbortController
javascript 复制代码
/**
 * SSE 流式请求客户端
 * 支持 POST 请求、自定义请求头、事件类型分发、中断控制
 */
class SSEStreamClient {
  constructor(options = {}) {
    this.baseURL = options.baseURL || '';
    this.defaultHeaders = options.headers || {};
    this.controller = null;
  }

  /**
   * 发起 SSE 流式请求
   * @param {string} url - 请求地址
   * @param {object} body - 请求体
   * @param {object} options - 可选配置
   * @param {object} options.headers - 额外请求头
   * @param {function} options.onMessage - 默认消息回调
   * @param {function} options.onEvent - 自定义事件回调 (event, data) => void
   * @param {function} options.onError - 错误回调
   * @param {function} options.onComplete - 流结束回调
   */
  async connect(url, body, options = {}) {
    // 创建 AbortController 用于中断请求
    this.controller = new AbortController();

    const headers = {
      'Content-Type': 'application/json',
      ...this.defaultHeaders,
      ...options.headers
    };

    try {
      const response = await fetch(`${this.baseURL}${url}`, {
        method: 'POST',
        headers,
        body: JSON.stringify(body),
        signal: this.controller.signal
      });

      if (!response.ok) {
        const errorText = await response.text();
        throw new Error(`HTTP ${response.status}: ${errorText}`);
      }

      await this._readStream(response, options);
    } catch (error) {
      if (error.name === 'AbortError') {
        console.log('请求已被取消');
      } else {
        options.onError?.(error);
        throw error;
      }
    } finally {
      this.controller = null;
    }
  }

  /**
   * 中断当前请求
   */
  abort() {
    this.controller?.abort();
  }

  /**
   * 读取并解析 SSE 流
   */
  async _readStream(response, options) {
    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let buffer = '';
    let lastEventId = '';

    try {
      while (true) {
        const { done, value } = 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.trim()) continue;

          const parsed = this._parseSSEMessage(part);
          if (!parsed) continue;

          lastEventId = parsed.id || lastEventId;

          // 分发事件
          if (parsed.event && parsed.event !== 'message') {
            options.onEvent?.(parsed.event, parsed.data, parsed.id);
          } else {
            options.onMessage?.(parsed.data, parsed.id);
          }
        }
      }

      // 处理缓冲区中剩余的数据
      if (buffer.trim()) {
        const parsed = this._parseSSEMessage(buffer);
        if (parsed) {
          if (parsed.event && parsed.event !== 'message') {
            options.onEvent?.(parsed.event, parsed.data, parsed.id);
          } else {
            options.onMessage?.(parsed.data, parsed.id);
          }
        }
      }

      options.onComplete?.(lastEventId);
    } catch (error) {
      if (error.name !== 'AbortError') {
        options.onError?.(error);
      }
    }
  }

  /**
   * 解析单条 SSE 消息
   * @param {string} raw - 原始消息文本
   * @returns {{ event?: string, data: string, id?: string, retry?: number } | null}
   */
  _parseSSEMessage(raw) {
    let event = '';
    let data = '';
    let id = '';
    let retry = null;

    for (const line of raw.split('\n')) {
      // 忽略注释行(以冒号开头)
      if (line.startsWith(':')) continue;

      const colonIndex = line.indexOf(':');
      if (colonIndex === -1) continue;

      const field = line.slice(0, colonIndex);
      let value = line.slice(colonIndex + 1);
      // 如果值以空格开头,去掉前导空格(SSE 规范)
      if (value.startsWith(' ')) value = value.slice(1);

      switch (field) {
        case 'event':
          event = value;
          break;
        case 'data':
          // data 字段可多行,用 \n 连接
          data += (data ? '\n' : '') + value;
          break;
        case 'id':
          id = value;
          break;
        case 'retry':
          retry = parseInt(value, 10);
          break;
      }
    }

    if (!data && !event) return null;

    return { event: event || 'message', data, id, retry };
  }
}
使用示例
javascript 复制代码
const client = new SSEStreamClient({
  baseURL: 'https://api.example.com',
  headers: {
    'Authorization': 'Bearer sk-xxxxx'
  }
});

// AI 对话流
await client.connect('/v1/chat/completions', {
  model: 'gpt-4',
  messages: [
    { role: 'user', content: '解释什么是 SSE' }
  ],
  stream: true
}, {
  onMessage(data) {
    try {
      const parsed = JSON.parse(data);
      const content = parsed.choices?.[0]?.delta?.content || '';
      document.getElementById('output').textContent += content;
    } catch {
      // 非 JSON 数据,直接显示
      document.getElementById('output').textContent += data;
    }
  },
  onEvent(event, data) {
    console.log(`[事件: ${event}]`, data);
  },
  onError(error) {
    console.error('SSE 错误:', error);
    document.getElementById('output').textContent += '\n\n[连接出错]';
  },
  onComplete() {
    console.log('流式传输完成');
    document.getElementById('output').textContent += '\n\n[完成]';
  }
});

// 需要中断时
// client.abort();

四、方案对比与选型建议

特性 EventSource fetch + ReadableStream
HTTP 方法 仅 GET 任意(GET/POST/PUT 等)
自定义请求头 ❌ 不支持 ✅ 完全支持
请求体 ❌ 不支持 ✅ 支持 JSON / FormData 等
自动重连 ✅ 内置 ❌ 需手动实现
Last-Event-ID ✅ 自动携带 ❌ 需手动实现
HTTP 状态码 ❌ 无法获取 ✅ 可获取
流解析 ✅ 浏览器自动 ❌ 需手动解析 SSE 格式
浏览器兼容性 ✅ 好(除 IE) ✅ 好(需支持 Streams API)
实现复杂度 ⭐ 极低 ⭐⭐⭐ 中等
典型场景 通知推送、实时监控 AI 对话、复杂查询

选型决策树:

sql 复制代码
需要 SSE?
├── 接口是 GET?
│   ├── 无需自定义请求头? ─── ✅ EventSource(最简方案)
│   └── 需要自定义请求头? ─── ⚠️ fetch + ReadableStream
│       (或通过 URL 参数传递 token,但需评估安全风险)
└── 接口是 POST?
    └── ✅ fetch + ReadableStream(唯一选择)

五、常见陷阱与最佳实践

5.1 陷阱:忽略 SSE 消息的边界问题

ReadableStreamread() 返回的是任意大小的字节块,一个 chunk 可能包含不完整的 SSE 消息,也可能包含多条消息。

javascript 复制代码
// ❌ 错误:假设每次 read() 恰好返回一条完整消息
const { value } = await reader.read();
const data = JSON.parse(decoder.decode(value)); // 可能抛出解析错误

// ✅ 正确:使用缓冲区拼接,按 \n\n 分割消息
let buffer = '';
while (true) {
  const { done, value } = 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) {
    // 处理完整消息...
  }
}

5.2 陷阱:忘记设置 { stream: true }

TextDecoder.decode() 的第二个参数 stream: true 告诉解码器:数据可能被截断在多字节字符中间,不要将未完成的字节序列替换为替换字符。

javascript 复制代码
// ❌ 可能导致中文字符丢失
buffer += decoder.decode(value);

// ✅ 正确:保留未完成的字节序列,等待下次拼接
buffer += decoder.decode(value, { stream: true });

5.3 陷阱:EventSource 连接泄漏

创建 EventSource 后忘记关闭,会导致连接堆积。

javascript 复制代码
// ❌ 错误:组件卸载时未关闭
useEffect(() => {
  const es = new EventSource('/api/updates');
  es.onmessage = (e) => setState(e.data);
  // 忘记 return cleanup!
}, []);

// ✅ 正确:组件卸载时关闭连接
useEffect(() => {
  const es = new EventSource('/api/updates');
  es.onmessage = (e) => setState(e.data);
  return () => es.close(); // 清理!
}, []);

5.4 陷阱:EventSource 的 CORS 问题

EventSource 不支持自定义请求头,因此无法在请求中携带 Authorization 头。如果跨域接口需要鉴权,只能:

  1. 使用 fetch + ReadableStream 方案
  2. 通过 URL 参数传递 token(不推荐,有安全风险)
  3. 使用 Cookie 鉴权(需要服务端配合 withCredentials
javascript 复制代码
// EventSource 的 withCredentials 选项(仅用于 Cookie)
const es = new EventSource('https://api.other.com/stream', {
  withCredentials: true // 发送跨域 Cookie
});

5.5 最佳实践:统一的错误处理

javascript 复制代码
async function fetchSSE(url, body, callbacks) {
  const response = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body)
  });

  // 处理各种 HTTP 错误状态码
  if (response.status === 401) {
    callbacks.onAuthError?.();
    return;
  }
  if (response.status === 429) {
    const retryAfter = response.headers.get('Retry-After');
    callbacks.onRateLimit?.(retryAfter);
    return;
  }
  if (!response.ok) {
    callbacks.onHttpError?.(response.status, response.statusText);
    return;
  }

  // 正常读取流...
  const reader = response.body.getReader();
  // ...
}

5.6 最佳实践:React 中封装自定义 Hook

javascript 复制代码
import { useEffect, useRef, useCallback, useState } from 'react';

/**
 * SSE 流式请求 Hook
 * @param {string} url - 请求地址
 * @param {object} options - 配置项
 */
function useSSEStream(url, options = {}) {
  const [data, setData] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const abortRef = useRef(null);

  const send = useCallback(async (body) => {
    setLoading(true);
    setData('');
    setError(null);

    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...options.headers
        },
        body: JSON.stringify(body),
        signal: controller.signal
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = '';

      while (true) {
        const { done, value } = 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.trim()) continue;
          for (const line of part.split('\n')) {
            if (!line.startsWith('data:')) continue;
            const content = line.slice(5).trimStart();
            if (content === '[DONE]') continue;
            try {
              const parsed = JSON.parse(content);
              const chunk = parsed.choices?.[0]?.delta?.content || '';
              if (chunk) setData(prev => prev + chunk);
            } catch {
              setData(prev => prev + content);
            }
          }
        }
      }
    } catch (err) {
      if (err.name !== 'AbortError') {
        setError(err.message);
      }
    } finally {
      setLoading(false);
      abortRef.current = null;
    }
  }, [url, options.headers]);

  const abort = useCallback(() => {
    abortRef.current?.abort();
  }, []);

  // 组件卸载时自动中断
  useEffect(() => {
    return () => abortRef.current?.abort();
  }, []);

  return { data, loading, error, send, abort };
}

// 使用
function ChatPanel() {
  const { data, loading, error, send, abort } = useSSEStream('/api/chat', {
    headers: { 'Authorization': 'Bearer xxx' }
  });

  return (
    <div>
      <pre>{data}</pre>
      <button onClick={() => send({ prompt: '你好' })} disabled={loading}>
        发送
      </button>
      {loading && <button onClick={abort}>停止生成</button>}
      {error && <p className="error">{error}</p>}
    </div>
  );
}

六、封装一个通用的 SSE 客户端

将两种方案统一封装,根据接口方法自动选择实现:

javascript 复制代码
/**
 * 统一 SSE 客户端
 * - GET 请求自动使用 EventSource(享受自动重连)
 * - POST 请求使用 fetch + ReadableStream(支持请求体和自定义头)
 */
class UnifiedSSEClient {
  constructor(baseURL = '', defaultHeaders = {}) {
    this.baseURL = baseURL;
    this.defaultHeaders = defaultHeaders;
  }

  /**
   * 发起 SSE 连接
   * @param {object} config
   * @param {string} config.url - 请求路径
   * @param {'GET'|'POST'} config.method - HTTP 方法
   * @param {object} config.body - 请求体(POST 时使用)
   * @param {object} config.headers - 额外请求头
   * @param {function} config.onMessage - 消息回调 (data: string) => void
   * @param {function} config.onEvent - 事件回调 (event: string, data: string) => void
   * @param {function} config.onError - 错误回调 (error: Error) => void
   * @param {function} config.onComplete - 完成回调 () => void
   * @returns {function} 取消函数
   */
  connect(config) {
    const method = (config.method || 'GET').toUpperCase();

    if (method === 'GET') {
      return this._connectViaEventSource(config);
    } else {
      this._connectViaFetch(config);
      // fetch 方案返回 abort 函数
      return () => this._abortController?.abort();
    }
  }

  // ─── GET: EventSource 方案 ───

  _connectViaEventSource(config) {
    const url = `${this.baseURL}${config.url}`;
    const es = new EventSource(url);

    es.onmessage = (event) => {
      config.onMessage?.(event.data, event.lastEventId);
    };

    // 监听自定义事件
    if (config.onEvent) {
      // 注意:addEventListener 需要提前知道事件名
      // 通用方案:监听所有事件需要重写 onmessage
      const originalOnMessage = es.onmessage;
      es.onmessage = null;

      es.addEventListener('message', (event) => {
        config.onMessage?.(event.data, event.lastEventId);
      });
    }

    es.onerror = (event) => {
      if (es.readyState === EventSource.CLOSED) {
        config.onComplete?.();
      } else {
        config.onError?.(new Error('EventSource connection error'));
      }
    };

    // 返回取消函数
    return () => es.close();
  }

  // ─── POST: fetch + ReadableStream 方案 ───

  async _connectViaFetch(config) {
    this._abortController = new AbortController();

    try {
      const response = await fetch(`${this.baseURL}${config.url}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          ...this.defaultHeaders,
          ...config.headers
        },
        body: JSON.stringify(config.body || {}),
        signal: this._abortController.signal
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      await this._readStream(response, config);
    } catch (error) {
      if (error.name !== 'AbortError') {
        config.onError?.(error);
      }
    } finally {
      this._abortController = null;
      config.onComplete?.();
    }
  }

  async _readStream(response, config) {
    const reader = response.body.getReader();
    const decoder = new TextDecoder('utf-8');
    let buffer = '';

    while (true) {
      const { done, value } = 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) {
        const parsed = this._parseSSE(part);
        if (!parsed) continue;

        if (parsed.event === 'message') {
          config.onMessage?.(parsed.data, parsed.id);
        } else {
          config.onEvent?.(parsed.event, parsed.data, parsed.id);
        }
      }
    }

    // 处理剩余数据
    if (buffer.trim()) {
      const parsed = this._parseSSE(buffer);
      if (parsed) {
        if (parsed.event === 'message') {
          config.onMessage?.(parsed.data, parsed.id);
        } else {
          config.onEvent?.(parsed.event, parsed.data, parsed.id);
        }
      }
    }
  }

  _parseSSE(raw) {
    let event = 'message';
    let data = '';
    let id = '';

    for (const line of raw.split('\n')) {
      if (line.startsWith(':')) continue;
      const idx = line.indexOf(':');
      if (idx === -1) continue;
      const field = line.slice(0, idx);
      let value = line.slice(idx + 1);
      if (value.startsWith(' ')) value = value.slice(1);

      if (field === 'event') event = value;
      else if (field === 'data') data += (data ? '\n' : '') + value;
      else if (field === 'id') id = value;
    }

    return data ? { event, data, id } : null;
  }
}

// ─── 使用示例 ───

const sse = new UnifiedSSEClient('https://api.example.com', {
  'Authorization': 'Bearer sk-xxxxx'
});

// GET 方式(自动使用 EventSource)
const disconnect = sse.connect({
  url: '/api/notifications',
  method: 'GET',
  onMessage(data) {
    console.log('通知:', data);
  }
});
// disconnect(); // 关闭连接

// POST 方式(自动使用 fetch + ReadableStream)
const abort = sse.connect({
  url: '/v1/chat/completions',
  method: 'POST',
  body: {
    model: 'gpt-4',
    messages: [{ role: 'user', content: 'Hello' }],
    stream: true
  },
  onMessage(data) {
    const parsed = JSON.parse(data);
    const chunk = parsed.choices?.[0]?.delta?.content || '';
    process.stdout.write(chunk);
  },
  onError(error) {
    console.error('出错:', error);
  }
});
// abort(); // 中断请求

总结

场景 推荐方案 核心理由
GET 接口,无需自定义请求头 EventSource 零配置、自动重连、API 简洁
POST 接口,需要请求体/自定义头 fetch + ReadableStream 唯一选择,灵活可控
需要统一管理两种场景 UnifiedSSEClient 一套 API 自动适配
  • SSE 的核心在于理解其协议格式(data:\n\n),无论用哪种客户端方案,底层都是解析这个格式。
  • EventSource 帮你自动解析了,而 fetch 方案需要你手动处理------但也因此获得了完全的控制力。
相关推荐
乐兮创想 小林1 小时前
B2B 内容营销的工程化运营:从内容矩阵建模到 SEO/GEO 联动的完整体系
前端·线性代数·矩阵·网站建设·北京网站建设公司
2501_940041741 小时前
全栈开发提速指南:可以直接用的项目生成提示词
前端·prompt
BomanGe21 小时前
NSK直线导轨LH55EL与NH55EM替代指南
前端·javascript·数据库·经验分享·规格说明书
云水一下1 小时前
Vue.js从零到精通系列(四):前端路由与Vue Router——打造多页单页应用
前端·javascript·vue.js
糯米导航1 小时前
浏览器解析HTML头部的底层逻辑:从字节流到渲染树的关键一步
前端·html
风骏时光牛马1 小时前
C++开发常见问题与解决方案汇总
前端
zhedream1 小时前
Vue 3 Teleport 报错实录:从 patch 时机到 `defer` 属性
前端·vue.js
雁北向1 小时前
自定义指令 数值输入显示优化 巴飞特 测试
前端·vue.js
研☆香1 小时前
jQuery补充知识点
前端·javascript·jquery