前端 SSE(Server-Sent Events)流式请求的实现

4 阅读9分钟

一、SSE 基本概念与工作原理

1.1 什么是 SSE?

Server-Sent Events(SSE) 是一种基于 HTTP 协议的服务器推送技术,允许服务器向客户端单向发送实时数据流。它使用简单的文本协议,通过 EventSource API 在浏览器端实现。

1.2 工作原理

┌─────────────┐                    ┌─────────────┐
│   客户端     │ ◄───────────────── │   服务器     │
│  (Browser)  │   HTTP 长连接       │   (Server)  │
│             │ ◄── data: 消息1     │             │
│ EventSource │ ◄── data: 消息2     │  持续推送    │
│             │ ◄── data: 消息3     │             │
└─────────────┘                    └─────────────┘

核心特点:

  • 基于 HTTP/1.1 或 HTTP/2
  • 单向通信(服务器 → 客户端)
  • 自动重连机制
  • 文本数据格式(支持 UTF-8)

1.3 SSE 消息格式

Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: 这是第一条消息

data: 这是第二条消息
id: 123
event: custom-event

: 这是注释(心跳检测)

data: {"type": "json", "content": "支持JSON格式"}

二、技术对比分析

特性SSEWebSocket长轮询短轮询
通信方向单向(服务器→客户端)双向单向单向
协议HTTPWebSocket (ws/wss)HTTPHTTP
实时性⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
复杂度
自动重连✅ 原生支持❌ 需手动实现
浏览器支持主流浏览器主流浏览器全部全部
防火墙穿透✅ 容易⚠️ 可能受阻✅ 容易✅ 容易
二进制支持❌ 仅文本✅ 支持✅ 支持✅ 支持
连接数限制HTTP/1.1: 6个/域无限制HTTP限制HTTP限制

选型建议

  • 选择 SSE:AI 流式输出、股票行情、新闻推送、日志实时展示
  • 选择 WebSocket:即时聊天、在线游戏、协同编辑、需要双向高频通信
  • 选择轮询:兼容性要求极高、简单场景、数据更新频率低

三、React 中实现 SSE 客户端

3.1 基础 SSE Hook

在你的 Vite + React 项目中创建以下文件:

// src/hooks/useSSE.ts
import { useEffect, useRef, useCallback, useState } from 'react';

export interface SSEMessage {
  id?: string;
  event?: string;
  data: string;
}

export interface UseSSEOptions {
  url: string;
  withCredentials?: boolean;
  onMessage?: (message: SSEMessage) => void;
  onOpen?: () => void;
  onError?: (error: Event) => void;
  onClose?: () => void;
  reconnectInterval?: number;
  maxReconnectAttempts?: number;
}

export interface UseSSEReturn {
  isConnected: boolean;
  lastMessage: SSEMessage | null;
  error: Event | null;
  connect: () => void;
  disconnect: () => void;
}

export const useSSE = (options: UseSSEOptions): UseSSEReturn => {
  const {
    url,
    withCredentials = false,
    onMessage,
    onOpen,
    onError,
    onClose,
    reconnectInterval = 3000,
    maxReconnectAttempts = 5,
  } = options;

  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState<SSEMessage | null>(null);
  const [error, setError] = useState<Event | null>(null);

  const eventSourceRef = useRef<EventSource | null>(null);
  const reconnectAttemptsRef = useRef(0);
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  // 清理函数
  const cleanup = useCallback(() => {
    if (reconnectTimerRef.current) {
      clearTimeout(reconnectTimerRef.current);
      reconnectTimerRef.current = null;
    }
    if (eventSourceRef.current) {
      eventSourceRef.current.close();
      eventSourceRef.current = null;
    }
  }, []);

  // 建立连接
  const connect = useCallback(() => {
    cleanup();

    try {
      const es = new EventSource(url, { withCredentials });
      eventSourceRef.current = es;

      // 连接打开
      es.onopen = () => {
        console.log('[SSE] 连接已建立');
        setIsConnected(true);
        setError(null);
        reconnectAttemptsRef.current = 0;
        onOpen?.();
      };

      // 接收消息
      es.onmessage = (event) => {
        const message: SSEMessage = {
          id: event.lastEventId || undefined,
          data: event.data,
        };
        setLastMessage(message);
        onMessage?.(message);
      };

      // 处理自定义事件
      es.addEventListener('custom-event', (event: MessageEvent) => {
        const message: SSEMessage = {
          id: event.lastEventId || undefined,
          event: event.type,
          data: event.data,
        };
        setLastMessage(message);
        onMessage?.(message);
      });

      // 错误处理
      es.onerror = (err) => {
        console.error('[SSE] 连接错误:', err);
        setIsConnected(false);
        setError(err);
        onError?.(err);

        // 自动重连逻辑
        if (reconnectAttemptsRef.current < maxReconnectAttempts) {
          reconnectAttemptsRef.current++;
          console.log(`[SSE] ${reconnectInterval}ms 后尝试第 ${reconnectAttemptsRef.current} 次重连...`);
          
          reconnectTimerRef.current = setTimeout(() => {
            connect();
          }, reconnectInterval);
        } else {
          console.error('[SSE] 达到最大重连次数,停止重连');
          cleanup();
          onClose?.();
        }
      };

    } catch (err) {
      console.error('[SSE] 创建连接失败:', err);
      setError(err as Event);
    }
  }, [url, withCredentials, onMessage, onOpen, onError, onClose, reconnectInterval, maxReconnectAttempts, cleanup]);

  // 断开连接
  const disconnect = useCallback(() => {
    console.log('[SSE] 手动断开连接');
    cleanup();
    setIsConnected(false);
    reconnectAttemptsRef.current = maxReconnectAttempts; // 防止自动重连
    onClose?.();
  }, [cleanup, maxReconnectAttempts, onClose]);

  // 组件卸载时清理
  useEffect(() => {
    return () => {
      cleanup();
    };
  }, [cleanup]);

  return {
    isConnected,
    lastMessage,
    error,
    connect,
    disconnect,
  };
};

3.2 使用示例组件

// src/components/SSEDemo.tsx
import { useSSE } from '../hooks/useSSE';

export const SSEDemo: React.FC = () => {
  const { isConnected, lastMessage, error, connect, disconnect } = useSSE({
    url: 'http://localhost:3000/api/sse',
    onMessage: (msg) => {
      console.log('收到消息:', msg.data);
    },
    onOpen: () => {
      console.log('SSE 连接成功');
    },
    onError: (err) => {
      console.error('SSE 错误:', err);
    },
    reconnectInterval: 3000,
    maxReconnectAttempts: 5,
  });

  return (
    <div className="p-6 max-w-2xl mx-auto">
      <h2 className="text-2xl font-bold mb-4">SSE 连接演示</h2>
      
      <div className="mb-4">
        <span className="font-semibold">连接状态: </span>
        <span className={isConnected ? 'text-green-600' : 'text-red-600'}>
          {isConnected ? '🟢 已连接' : '🔴 未连接'}
        </span>
      </div>

      {error && (
        <div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
          连接错误: {error.type}
        </div>
      )}

      <div className="mb-4">
        <span className="font-semibold">最新消息: </span>
        <div className="mt-2 p-3 bg-gray-100 rounded min-h-[60px]">
          {lastMessage?.data || '等待消息...'}
        </div>
      </div>

      <div className="space-x-4">
        <button
          onClick={connect}
          disabled={isConnected}
          className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
        >
          连接
        </button>
        <button
          onClick={disconnect}
          disabled={!isConnected}
          className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400"
        >
          断开
        </button>
      </div>
    </div>
  );
};

四、连接生命周期管理

4.1 完整生命周期图示

┌─────────┐    connect()    ┌─────────┐
│  初始状态  │ ─────────────► │ 连接中   │
│ (closed) │                │(connecting)
└─────────┘                └────┬────┘
     ▲                          │
     │                    onopen │
     │                          ▼
     │                    ┌─────────┐
     │  onerror + 重连超限  │  已连接   │
     └─────────────────── │ (open)  │
                          └────┬────┘
                               │
              ┌────────────────┼────────────────┐
              │                │                │
              ▼                ▼                ▼
          onmessage        自定义事件        disconnect()
         (接收数据)         (custom-event)    (手动断开)
              │                │                │
              └────────────────┘                │
                               │                ▼
                               │           ┌─────────┐
                               └──────────►│  已关闭   │
                                           │ (closed) │
                                           └─────────┘

4.2 高级连接管理 Hook

// src/hooks/useSSEAdvanced.ts
import { useEffect, useRef, useCallback, useState, useReducer } from 'react';

// 连接状态机
type ConnectionState = 
  | 'idle' 
  | 'connecting' 
  | 'connected' 
  | 'reconnecting' 
  | 'closed' 
  | 'error';

interface SSEState {
  status: ConnectionState;
  messages: string[];
  error: Error | null;
  reconnectCount: number;
}

type SSEAction =
  | { type: 'CONNECTING' }
  | { type: 'CONNECTED' }
  | { type: 'MESSAGE_RECEIVED'; payload: string }
  | { type: 'ERROR'; payload: Error }
  | { type: 'RECONNECTING' }
  | { type: 'CLOSED' }
  | { type: 'CLEAR_MESSAGES' };

const initialState: SSEState = {
  status: 'idle',
  messages: [],
  error: null,
  reconnectCount: 0,
};

function sseReducer(state: SSEState, action: SSEAction): SSEState {
  switch (action.type) {
    case 'CONNECTING':
      return { ...state, status: 'connecting', error: null };
    case 'CONNECTED':
      return { ...state, status: 'connected', error: null, reconnectCount: 0 };
    case 'MESSAGE_RECEIVED':
      return { ...state, messages: [...state.messages, action.payload] };
    case 'ERROR':
      return { ...state, status: 'error', error: action.payload };
    case 'RECONNECTING':
      return { 
        ...state, 
        status: 'reconnecting', 
        reconnectCount: state.reconnectCount + 1 
      };
    case 'CLOSED':
      return { ...state, status: 'closed' };
    case 'CLEAR_MESSAGES':
      return { ...state, messages: [] };
    default:
      return state;
  }
}

export const useSSEAdvanced = (url: string) => {
  const [state, dispatch] = useReducer(sseReducer, initialState);
  const esRef = useRef<EventSource | null>(null);
  const shouldReconnectRef = useRef(true);

  // 清理连接
  const cleanup = useCallback(() => {
    if (esRef.current) {
      // 移除所有事件监听器
      esRef.current.onopen = null;
      esRef.current.onmessage = null;
      esRef.current.onerror = null;
      esRef.current.close();
      esRef.current = null;
    }
  }, []);

  // 连接函数
  const connect = useCallback(() => {
    cleanup();
    dispatch({ type: 'CONNECTING' });

    try {
      const es = new EventSource(url);
      esRef.current = es;

      es.onopen = () => {
        dispatch({ type: 'CONNECTED' });
      };

      es.onmessage = (event) => {
        // 处理心跳消息
        if (event.data === 'ping') {
          console.log('[SSE] 收到心跳');
          return;
        }
        dispatch({ type: 'MESSAGE_RECEIVED', payload: event.data });
      };

      es.onerror = (error) => {
        dispatch({ 
          type: 'ERROR', 
          payload: new Error('SSE 连接错误') 
        });
        
        if (shouldReconnectRef.current) {
          dispatch({ type: 'RECONNECTING' });
          // 指数退避重连
          const delay = Math.min(1000 * Math.pow(2, state.reconnectCount), 30000);
          setTimeout(() => {
            if (shouldReconnectRef.current) {
              connect();
            }
          }, delay);
        }
      };

    } catch (err) {
      dispatch({ 
        type: 'ERROR', 
        payload: err instanceof Error ? err : new Error('未知错误') 
      });
    }
  }, [url, cleanup, state.reconnectCount]);

  // 断开连接
  const disconnect = useCallback(() => {
    shouldReconnectRef.current = false;
    cleanup();
    dispatch({ type: 'CLOSED' });
  }, [cleanup]);

  // 清空消息
  const clearMessages = useCallback(() => {
    dispatch({ type: 'CLEAR_MESSAGES' });
  }, []);

  // 组件卸载清理
  useEffect(() => {
    return () => {
      shouldReconnectRef.current = false;
      cleanup();
    };
  }, [cleanup]);

  return {
    ...state,
    connect,
    disconnect,
    clearMessages,
    isConnected: state.status === 'connected',
    isConnecting: state.status === 'connecting',
    isReconnecting: state.status === 'reconnecting',
  };
};

五、AI 对话流式输出实战

5.1 核心 Hook:useChatStream

// src/hooks/useChatStream.ts
import { useState, useCallback, useRef } from 'react';

export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp: number;
  isStreaming?: boolean;
}

interface StreamChunk {
  content?: string;
  done?: boolean;
  error?: string;
}

export const useChatStream = (apiEndpoint: string) => {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);
  const [streamingContent, setStreamingContent] = useState('');
  const abortControllerRef = useRef<AbortController | null>(null);

  // 生成唯一 ID
  const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

  // 发送消息并接收流式响应
  const sendMessage = useCallback(async (content: string) => {
    if (!content.trim() || isStreaming) return;

    // 添加用户消息
    const userMessage: ChatMessage = {
      id: generateId(),
      role: 'user',
      content: content.trim(),
      timestamp: Date.now(),
    };

    // 创建 AI 回复占位
    const assistantMessageId = generateId();
    const assistantMessage: ChatMessage = {
      id: assistantMessageId,
      role: 'assistant',
      content: '',
      timestamp: Date.now(),
      isStreaming: true,
    };

    setMessages(prev => [...prev, userMessage, assistantMessage]);
    setIsStreaming(true);
    setStreamingContent('');

    // 创建 AbortController 用于取消请求
    abortControllerRef.current = new AbortController();

    try {
      const response = await fetch(apiEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream',
        },
        body: JSON.stringify({
          messages: [...messages, userMessage].map(m => ({
            role: m.role,
            content: m.content,
          })),
          stream: true,
        }),
        signal: abortControllerRef.current.signal,
      });

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

      if (!response.body) {
        throw new Error('Response body is null');
      }

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

      // 读取流式数据
      while (true) {
        const { done, value } = await reader.read();
        
        if (done) break;

        // 解码数据块
        const chunk = decoder.decode(value, { stream: true });
        
        // 解析 SSE 格式的数据
        const lines = chunk.split('\n');
        
        for (const line of lines) {
          if (line.startsWith('data: ')) {
            const data = line.slice(6).trim();
            
            // 处理结束标记
            if (data === '[DONE]') {
              continue;
            }

            try {
              const parsed: StreamChunk = JSON.parse(data);
              
              if (parsed.error) {
                throw new Error(parsed.error);
              }

              if (parsed.content) {
                accumulatedContent += parsed.content;
                setStreamingContent(accumulatedContent);
                
                // 实时更新消息列表
                setMessages(prev => 
                  prev.map(msg => 
                    msg.id === assistantMessageId
                      ? { ...msg, content: accumulatedContent }
                      : msg
                  )
                );
              }

              if (parsed.done) {
                break;
              }
            } catch (e) {
              // 非 JSON 格式,直接追加
              if (data && data !== '[DONE]') {
                accumulatedContent += data;
                setStreamingContent(accumulatedContent);
                setMessages(prev => 
                  prev.map(msg => 
                    msg.id === assistantMessageId
                      ? { ...msg, content: accumulatedContent }
                      : msg
                  )
                );
              }
            }
          }
        }
      }

      // 标记流式传输完成
      setMessages(prev => 
        prev.map(msg => 
          msg.id === assistantMessageId
            ? { ...msg, isStreaming: false }
            : msg
        )
      );

    } catch (error) {
      if (error instanceof Error && error.name === 'AbortError') {
        console.log('请求被取消');
      } else {
        console.error('流式请求错误:', error);
        // 更新消息显示错误
        setMessages(prev => 
          prev.map(msg => 
            msg.id === assistantMessageId
              ? { ...msg, content: '❌ 发送失败,请重试', isStreaming: false }
              : msg
          )
        );
      }
    } finally {
      setIsStreaming(false);
      setStreamingContent('');
      abortControllerRef.current = null;
    }
  }, [apiEndpoint, messages, isStreaming]);

  // 停止生成
  const stopGeneration = useCallback(() => {
    if (abortControllerRef.current) {
      abortControllerRef.current.abort();
      abortControllerRef.current = null;
      
      // 标记最后一条消息停止流式传输
      setMessages(prev => {
        const lastMsg = prev[prev.length - 1];
        if (lastMsg?.role === 'assistant' && lastMsg.isStreaming) {
          return prev.map((msg, idx) => 
            idx === prev.length - 1
              ? { ...msg, isStreaming: false }
              : msg
          );
        }
        return prev;
      });
      
      setIsStreaming(false);
    }
  }, []);

  // 清空对话
  const clearChat = useCallback(() => {
    stopGeneration();
    setMessages([]);
  }, [stopGeneration]);

  return {
    messages,
    isStreaming,
    streamingContent,
    sendMessage,
    stopGeneration,
    clearChat,
  };
};

5.2 聊天界面组件

// src/components/ChatInterface.tsx
import React, { useState, useRef, useEffect } from 'react';
import { useChatStream } from '../hooks/useChatStream';
import { Send, Square, Trash2, Bot, User } from 'lucide-react'; // 需要安装 lucide-react

export const ChatInterface: React.FC = () => {
  const [input, setInput] = useState('');
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLTextAreaElement>(null);

  const {
    messages,
    isStreaming,
    sendMessage,
    stopGeneration,
    clearChat,
  } = useChatStream('/api/chat');

  // 自动滚动到底部
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  // 处理发送
  const handleSend = () => {
    if (!input.trim() || isStreaming) return;
    sendMessage(input);
    setInput('');
    // 重置输入框高度
    if (inputRef.current) {
      inputRef.current.style.height = 'auto';
    }
  };

  // 处理键盘事件
  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  // 自动调整输入框高度
  const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setInput(e.target.value);
    e.target.style.height = 'auto';
    e.target.style.height = `${Math.min(e.target.scrollHeight, 200)}px`;
  };

  return (
    <div className="flex flex-col h-screen bg-gray-50">
      {/* 头部 */}
      <header className="bg-white border-b px-6 py-4 flex items-center justify-between">
        <div className="flex items-center gap-3">
          <Bot className="w-8 h-8 text-blue-600" />
          <h1 className="text-xl font-semibold">AI 助手</h1>
        </div>
        <button
          onClick={clearChat}
          className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
          title="清空对话"
        >
          <Trash2 className="w-5 h-5" />
        </button>
      </header>

      {/* 消息列表 */}
      <div className="flex-1 overflow-y-auto p-6 space-y-6">
        {messages.length === 0 ? (
          <div className="text-center text-gray-400 mt-20">
            <Bot className="w-16 h-16 mx-auto mb-4 opacity-50" />
            <p className="text-lg">开始与 AI 对话吧</p>
            <p className="text-sm mt-2">输入问题,AI 将流式回复</p>
          </div>
        ) : (
          messages.map((msg) => (
            <div
              key={msg.id}
              className={`flex gap-4 ${
                msg.role === 'user' ? 'flex-row-reverse' : ''
              }`}
            >
              {/* 头像 */}
              <div
                className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
                  msg.role === 'user'
                    ? 'bg-blue-600 text-white'
                    : 'bg-gray-200 text-gray-600'
                }`}
              >
                {msg.role === 'user' ? (
                  <User className="w-5 h-5" />
                ) : (
                  <Bot className="w-5 h-5" />
                )}
              </div>

              {/* 消息内容 */}
              <div
                className={`max-w-[80%] rounded-2xl px-5 py-3 ${
                  msg.role === 'user'
                    ? 'bg-blue-600 text-white'
                    : 'bg-white border shadow-sm'
                }`}
              >
                <div className="whitespace-pre-wrap leading-relaxed">
                  {msg.content}
                  {msg.isStreaming && (
                    <span className="inline-block w-2 h-5 ml-1 bg-current animate-pulse" />
                  )}
                </div>
                <div
                  className={`text-xs mt-2 ${
                    msg.role === 'user' ? 'text-blue-200' : 'text-gray-400'
                  }`}
                >
                  {new Date(msg.timestamp).toLocaleTimeString()}
                </div>
              </div>
            </div>
          ))
        )}
        <div ref={messagesEndRef} />
      </div>

      {/* 输入区域 */}
      <div className="bg-white border-t p-4">
        <div className="max-w-4xl mx-auto flex gap-3">
          <div className="flex-1 relative">
            <textarea
              ref={inputRef}
              value={input}
              onChange={handleInputChange}
              onKeyDown={handleKeyDown}
              placeholder="输入消息... (Shift+Enter 换行)"
              disabled={isStreaming}
              rows={1}
              className="w-full px-4 py-3 pr-12 border rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
            />
            <div className="absolute right-3 bottom-3 text-xs text-gray-400">
              {input.length} 字
            </div>
          </div>

          {isStreaming ? (
            <button
              onClick={stopGeneration}
              className="px-6 py-3 bg-red-600 text-white rounded-xl hover:bg-red-700 transition-colors flex items-center gap-2"
            >
              <Square className="w-4 h-4 fill-current" />
              停止
            </button>
          ) : (
            <button
              onClick={handleSend}
              disabled={!input.trim()}
              className="px-6 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
            >
              <Send className="w-4 h-4" />
              发送
            </button>
          )}
        </div>
        <p className="text-center text-xs text-gray-400 mt-2">
          AI 生成的内容仅供参考
        </p>
      </div>
    </div>
  );
};

5.3 模拟后端(用于测试)

// src/mocks/mockAIStream.ts
// 用于开发测试的模拟 SSE 服务端

export const mockAIStream = () => {
  const mockResponses = [
    "你好!我是 AI 助手。",
    "我可以帮助你解答问题、编写代码、分析数据等。",
    "这是一个模拟的流式响应,用于测试前端 SSE 功能。",
    "在实际应用中,这里会是真实的 AI 模型返回的内容。",
    "SSE 流式输出可以让用户实时看到 AI 的思考过程,提升用户体验。"
  ];

  return new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      
      for (const text of mockResponses) {
        // 模拟逐字输出
        for (const char of text) {
          const chunk = {
            content: char,
            done: false
          };
          
          controller.enqueue(
            encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)
          );
          
          // 模拟打字延迟
          await new Promise(resolve => setTimeout(resolve, 50));
        }
        
        // 句子间停顿
        await new Promise(resolve => setTimeout(resolve, 300));
      }

      // 发送结束标记
      controller.enqueue(encoder.encode('data: [DONE]\n\n'));
      controller.close();
    }
  });
};

// 在开发环境中拦截 fetch
if (import.meta.env.DEV) {
  const originalFetch = window.fetch;
  
  window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
    const url = input.toString();
    
    if (url.includes('/api/chat')) {
      const stream = mockAIStream();
      
      return new Response(stream, {
        headers: {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Connection': 'keep-alive',
        },
      });
    }
    
    return originalFetch(input, init);
  };
}

5.4 集成到 App

// src/App.tsx
import { ChatInterface } from './components/ChatInterface';
import './App.css';

function App() {
  return (
    <div className="min-h-screen bg-gray-50">
      <ChatInterface />
    </div>
  );
}

export default App;

六、优势、局限性与最佳实践

6.1 SSE 的优势

优势说明
简单易用基于 HTTP,无需额外协议升级
自动重连原生支持,无需手动实现
轻量级单向通信,开销小于 WebSocket
兼容性好支持 HTTP/2 多路复用
调试方便纯文本协议,易于抓包分析

6.2 SSE 的局限性

局限性解决方案
单向通信需要双向时用 WebSocket 或配合 HTTP POST
仅支持文本二进制数据用 Base64 编码
连接数限制HTTP/2 可解决,或使用多个域名
不支持自定义 Header使用 Cookie 或 URL 参数传递认证信息

6.3 最佳实践清单

✅ 应该做的

// 1. 始终处理连接错误
es.onerror = (error) => {
  console.error('SSE 错误:', error);
  // 实现重连逻辑
};

// 2. 使用 AbortController 支持取消
const controller = new AbortController();
fetch(url, { signal: controller.signal });

// 3. 实现心跳检测
setInterval(() => {
  if (Date.now() - lastMessageTime > 30000) {
    reconnect();
  }
}, 10000);

// 4. 合理设置重连策略
const backoff = Math.min(1000 * Math.pow(2, attempts), 30000);

// 5. 组件卸载时清理
useEffect(() => {
  return () => {
    eventSource.close();
  };
}, []);

❌ 不应该做的

// 1. 不要忽略错误处理
// ❌ 错误
es.onerror = null;

// 2. 不要在连接未关闭时创建新连接
// ❌ 错误
if (!es) {
  es = new EventSource(url); // 可能已有连接
}

// 3. 不要忘记清理事件监听器
// ❌ 错误
// 直接关闭而不移除监听器可能导致内存泄漏

// 4. 不要在前一个请求未完成时发送新请求
// ❌ 错误
const send = () => {
  fetch('/api/chat', { body: JSON.stringify({ msg }) });
  // 没有检查是否正在流式传输
};

6.4 性能优化建议

// 1. 使用虚拟列表渲染大量消息
import { Virtuoso } from 'react-virtuoso';

<Virtuoso
  data={messages}
  itemContent={(index, message) => <MessageItem {...message} />}
/>

// 2. 防抖处理输入
import { useDebouncedCallback } from 'use-debounce';

const debouncedSend = useDebouncedCallback(sendMessage, 300);

// 3. 使用 Web Worker 处理消息解析
// worker.ts
self.onmessage = (e) => {
  const parsed = parseSSEChunk(e.data);
  self.postMessage(parsed);
};

// 4. 启用 HTTP/2 服务器推送(如果支持)

总结

SSE 是实现 AI 对话流式输出的理想选择,它简单、可靠且易于维护。在 React 项目中,通过自定义 Hook 可以有效管理 SSE 连接的生命周期,结合 TypeScript 可以获得完整的类型支持。

关键要点:

  1. 使用 EventSource 处理简单场景
  2. 使用 fetch + ReadableStream 处理需要自定义请求头的场景
  3. 始终实现错误处理和自动重连
  4. 注意组件卸载时的资源清理
  5. 针对 AI 场景优化用户体验(打字机效果、停止生成等)

希望这篇指南能帮助你顺利实现 SSE 流式请求功能!如有问题,欢迎继续讨论。