【实战经验】uni-app使用 SSE 踩坑,EventSource不支持怎么办?

一、前言

最近在开发一个移动端应用软件,需要快速做到跨平台效果,我就基于 uni-app 的项目,在这个 APP 应用中其中有一个 AI 对话的功能。

常见的实现这样类似ChatGPT、DeepSeek的流式输出打字机效果,后端并不是等待 AI 完整的生成以后一次性返回,而是采用 SSE(Sever-Sent-Events),让服务器生成一大部分内容,然后就立马推送这一部分内容到前端。

SSE(Server-Sent Events)是一种基于 HTTP 协议的服务器推送技术,允许服务端主动向客户端发送数据流。

整个的流程是:

复制代码
uni-app
   ↓
发送聊天请求
   ↓
Java 后端
   ↓
调用 AI 大模型
   ↓
模型持续产生内容
   ↓
SSE 持续推送
   ↓
前端逐步显示 AI 回复

最开始,在这个功能完整落地到APP端前,我自己创建了一个空白的H5 Web页面让 AI 帮我简单制作,进行功能性测试,于是我很自然的想到一下这些实例代码:

js 复制代码
const eventSource = new EventSource('/api/chat/stream')

eventSource.onmessage = (event) => {
    console.log(event.data)
}

eventSource.onerror = (error) => {
    console.error(error)
}

因为现在几乎大部分支持HTTP协议的基本上都是支持SSE,我在浏览器上的H5 Web端理所当然就是测试成功,一切顺利。

然后我开始准备直接在uniapp工程项目中的 AI对话页面开始实施应用,但是我在项目真正运行的时候,问题开始来了。

熟悉的 EventSources 方案不能直接照搬。

我让AI来辅助我,它直接写的就是用uni.reqeust() 去弄 SSE,当时我还觉得没啥问题,然后最后发现,请求压根没有发出,然后后端一点日志消息也没有,前端uniapp部分也没有任何日志消息。

于是我开始排查:

是后端 SSE 写错了吗?
是Spring AI 配置有问题吗?
是 Nginx 把连接断掉了吗?
还是我这里 uniapp 使用的问题?

最后发现,这个问题其实设计一个非常容易被忽略的知识点:

uni-app 的不同的平台, 并不等于浏览器环境

这篇文章就是记录一下这次真实项目中的踩坑过程,以及最终的解决思路。

二、先搞清楚:什么是 SSE?

我这里主要讲解 uniapp 使用 SSE踩坑过程,不重点多篇幅讲解 SSE,我这里将简单概括一下。

SSE全称: Server-Sent Events,它是一种基于 HTTP 的服务器推送技术,SSE 可以被理解为 HTTP 的一个扩展或一种特定用法。它不是一个全新的、独立的协议,而是构建在标准 HTTP/1.1 协议之上的技术。

SSE 就像是服务器打开了一个"单向数据管道",服务器通过HTTP 扩展 可以持续不断地流向浏览器,无需客户端反复发起请求。其实很简单的: SSE = HTTP 扩展字段 + Keepalive 长连接。

SSE 提供了一种简单、可靠的方式来实现服务器向客户端的实时数据推送。它非常适合通知、实时数据更新、日志流和类似 ChatGPT 的逐字输出场景。如果你只需要单向通信,SSE 往往是比 WebSocket 更简单、更轻量的选择。

SSE 适用于服务器主动向客户端推送数据的场景,如实时通知、动态更新等。

所以,目前 几乎所有主流浏览器都原生支持SSE。

SSE 因其简单的 API、基于 HTTP 带来的良好兼容性(如无需担心代理或防火墙问题)、以及自动重连等特性,在只需要服务器向客户端推送数据的场景中(如新闻推送、实时行情、状态更新、AI 处理进度流式输出等)成为了更简单、更合适的选择。

ChatGPT、Claude 等生成式 AI 需要"打字机"式逐 token 输出,SSE 天然契合:

1)基于 HTTP/1.1 无需升级协议,CDN 缓存友好;

2)浏览器 EventSource API 一行代码即可接入;

3)文本流可直接承载 JSON Lines 或 markdown 片段。

2022 年底起:OpenAI、Anthropic、Google Bard 均把 text/event-stream 作为官方流式回答协议,社区库(FastAPI SSE-Star、Spring WebFlux、Node sse.js、Go gin-sse)迎来二次繁荣。

三、浏览器中 SSE 非常简单

浏览器支持情况

如果是在传统的 Web项目中,我们可以直接使用浏览器提供的 EventSource,来直接去实现用 SSE,因为主流的浏览器几乎都支持。

实例:

js 复制代码
const eventSource = new EventSource(
    '/api/chat/stream'
)

eventSource.onopen = () => {
    console.log('SSE连接成功')
}

eventSource.onmessage = (event) => {
    console.log('收到消息:', event.data)
}

eventSource.onerror = (error) => {
    console.error('SSE异常:', error)
}

MDN 官方文档中同样提供了这种使用方式:

js 复制代码
const evtSource = new EventSource("sse.php");

evtSource.onmessage = (event) => {
    console.log(event.data);
};

而且目前主流浏览器对 EventSource 的支持已经非常成熟。MDN 将其标记为 Baseline,并说明该能力已经在主流浏览器中广泛可用。(⁠MDN Web Docs)

前端框架支持情况

React

在React中,社区更倾向于使用封装好的Hook来管理SSE连接的状态和生命周期,让代码更清晰。

推荐方案:使用@longdog/usessereact-use-sse 这类Hook

js 复制代码
// 使用 @longdog/usesse 的示例
import { makeUseSSE } from "@longdog/usesse";

// 定义服务器返回的数据类型
type ServerTime = { time: string };

// 创建用于特定事件的自定义Hook
const useTimeSSE = makeUseSSE<ServerTime>("/api/sse/time", "time-update");

function Clock() {
  // 使用Hook,data会随着服务器推送自动更新
  const data = useTimeSSE();

  return <div>{data?.time || "等待时间..."}</div>;
}

这个Hook自动处理了连接、事件监听和重新渲染,你只需要关心数据如何展示即可。

Vue

在Vue中,你通常会在组件挂载时建立连接,并在卸载前关闭它,同时利用响应式数据来驱动视图更新。

直接使用原生 EventSource API 是常见且直接的方式。

js 复制代码
<template>
  <div>
    <p>状态: {{ connectionStatus }}</p>
    <ul>
      <li v-for="(msg, index) in messages" :key="index">{{ msg }}</li>
    </ul>
  </div>
</template>

<script>
export default {
  data() {
    return {
      eventSource: null,
      messages: [],
      connectionStatus: '未连接'
    };
  },
  mounted() {
    // 组件挂载时建立SSE连接
    this.eventSource = new EventSource('https://api.example.com/events');
    this.connectionStatus = '连接中...';

    this.eventSource.onopen = () => {
      this.connectionStatus = '已连接';
    };

    // 监听名为 'notification' 的事件
    this.eventSource.addEventListener('notification', (event) => {
      const data = JSON.parse(event.data);
      this.messages.push(data.message);
    });

    this.eventSource.onerror = () => {
      this.connectionStatus = '连接出错,尝试重连...';
    };
  },
  beforeUnmount() {
    // 组件卸载前务必关闭连接,防止内存泄漏
    if (this.eventSource) {
      this.eventSource.close();
      this.eventSource = null;
    }
  }
};
</script>

Angular

在Angular这个高度依赖依赖注入(DI)的框架中,社区有专门的库(如 ngx-sse-client),它利用Angular的 HttpClient,使得SSE请求也能享受拦截器等强大特性。

推荐方案:使用 ngx-sse-client 库,它更符合Angular的开发范式

ts 复制代码
import { Component } from '@angular/core';
import { SseClient } from 'ngx-sse-client';

@Component({
  selector: 'app-root',
  template: `<div *ngFor="let msg of messages">{{ msg }}</div>`
})
export class AppComponent {
  messages: string[] = [];

  constructor(private sseClient: SseClient) {
    // 发起流式请求
    this.sseClient.stream('/api/stream', { keepAlive: true, responseType: 'event' })
      .subscribe((event) => {
        // event 是 MessageEvent 类型
        this.messages.push(event.data);
      });
  }
}

所以在常见的 Web 项目中 SSE 的使用基本上不会有很大的问题。

四、真正的坑:uni-app并不等于浏览器

uniapp作为跨平台框架,本质原理其实也是"内嵌浏览器运行本地网页",很多刚开始使用 uni-app 的开发者容易产生一个误解:

uni-app 写的是 JavaScript,所以浏览器里面的 JavaScript API 应该都能使用。

实际上并不是,uniapp官方文档中就明确说明:

非 Web 端虽然支持标准 ECMAScript, 但是并不支持 window、document、navigator 等浏览器专用的对象。

同时 uniapp 还提供各端自己的特色 API,并且可以通过条件编译调用平台能力。

所以说:H5环境 ≠ 小程序环境 ≠ iOS环境 ≠ Android环境 ≠ 鸿蒙环境

他们不能像常规 Web 项目一样去实现 SSE 功能。

五、为什么 Web 能运行,App端或者其他端却出现问题

假设我们有下面这段代码:

js 复制代码
const eventSource = new EventSource(url)

如果现在uniapp启动,比如说H5端,在web浏览器环境中,是可以实现正常的 SSE 流式效果,因为浏览器本身就提供了 EventSource 这个能力。

但是如果现在我们在uniapp 启动 APP 端 iOS或者 Android,我们实际上已经不在传统的浏览器页面环境中运行这段代码,因为这些端的中使用的环境,不一定像常见主流的浏览器一样,提供了这种方法。

我们不能简单认为:H5 能跑,APP一定也能跑

这也跨平台开发非常容易踩到的坑。

uniapp官方 API 文档本身也强调,各平台 API 存在差异,并提供平台特色的API和条件编译机制来处理这些差异。

六、第一次踩坑:直接使用 EventSource

项目最开始,我的思路非常简单:

js 复制代码
const source = new EventSource(
    'https://example.com/api/chat/stream'
)

然后监听:

js 复制代码
source.onmessage = (event) => {
    console.log(event.data)
}

Web/H5在浏览器中运行的时候没有问题一切正常,但我使用的是iOS仿真,无法按照预期工作,并且没有任何报错信息,日志信息都没有。我甚至一直认为后端 SSE 接口写的问题。然后一直在给后端添加调试日志,来不停的测试,到最后,后端实在挑不出什么问题,我才意识到这里 uniapp 出现的问题导致的。

非常容易误判成 后端 SSE 接口有问题。

七、第二次踩坑:在AI的忽悠下直接使用 uni.request()

既然 EventSource 无法正常使用在APP端,我也是想不出来,我就想着 SSE 本身也是基于 HTTP协议来的,那么是不是 uni.request() 是不是可以这样使用。然后在 AI 的添油加醋下,就改成使用 uniapp 的方式。

例如:

js 复制代码
uni.request({
    url: 'https://example.com/api/chat',
    method: 'POST',
    data: {
        message: '你好'
    },
    success(res) {
        console.log(res.data)
    }
})

但是我有会发现,这里又出现一个问题,普通的 HTTP 请求和 SSE 的思维方式并不一样。

普通请求:

复制代码
客户端
   ↓
HTTP Request
   ↓
服务器处理
   ↓
完整 Response
   ↓
success

而 SSE:

复制代码
客户端
   ↓
HTTP Request
   ↓
建立连接
   ↓
服务器持续发送
   ↓
客户端持续接收
   ↓
连接结束

如果按照普通的请求方法来等待:

js 复制代码
success(res) {
    console.log(res.data)
}

那么你拿到的往往是整个请求完成之后的结果,而不是我们想要的:收到一个Token就马上显示,打字机的效果,这对于 AI 流式输出显而不够的。

然后我开始查阅资料

在查找后,发现Uniapp社区中基本上大部分说法都是指向:uniapp暂不支持SSE

八、较为理想解决方案:使用 XMLHttpRequest

在放弃通过uniapp提供的API和传统EventSource方法后,我换了一个思路,SSE本质上还是 HTTP 长连接,为什么要用这些呢?

SSE 是一种数据传输格式,而:EventSource只是浏览器帮我们封装好的 SSE 客户端 API

如果我们不使用 EventSource,我们通过 XMLHttpReuqest 实现HTTP长连接,持续接受响应,读取 responseText,然后自己解析 SSE,是不是也可以实现?

在得到思路启发后,我自己编写了一个插件。

我这里直接附上源码:

js 复制代码
/**
 * uni-sse-client 跨端SSE流式通信插件
 */

import { getLines, getMessages } from './parser.js';

// 常量定义
const DEFAULT_RETRY_INTERVAL = 1000;
const RETRY_BACKOFF_MULTIPLIER = 1.5;

/**
 * 基于你项目优化的SSE客户端
 * 适配微信小程序环境的真实流式处理
 */
class SSEClient {
  constructor(options = {}) {
    this.timeout = options.timeout || 60000;
    this.heartbeatTimeout = options.heartbeatTimeout || 30000;
    this.maxRetryCount = options.maxRetryCount || 3;
    
    this.requestTask = null;
    this.heartbeatTimer = null;
    this.timeoutTimer = null;
    this.retryCount = 0;
    this.isFailed = false;
    this.isConnected = false;
    
    // 回调函数
    this.onMessageCallback = null;
    this.onErrorCallback = null;
    this.onOpenCallback = null;
    this.onCloseCallback = null;
    
    // 检测运行环境
    this.environment = this._detectEnvironment();
    
    // 初始化数据处理器(基于你项目的解析逻辑)
    const onLine = getMessages(() => {}, () => {}, (line) => {
      this.onMessageCallback && this.onMessageCallback({
        type: 'message',
        data: line.data,
        raw: line,
        event: line.event || 'message'
      });
    });
    this.onChunk = getLines(onLine);
  }

  /**
   * 检测当前运行环境
   */
  _detectEnvironment() {
    // #ifdef H5
    return 'h5';
    // #endif
    // #ifdef MP-WEIXIN
    return 'mp-weixin';
    // #endif
    // #ifdef MP-ALIPAY
    return 'mp-alipay';
    // #endif
    // #ifdef APP-PLUS
    return 'app-plus';
    // #endif
    return 'unknown';
  }

  /**
   * 连接SSE服务
   * 优先使用 fetch + ReadableStream(支持真正流式)
   * 降级使用 uni.request(iOS 不支持分块传输,会缓冲整个响应)
   */
  connect(url, options = {}) {
    const {
      method = 'POST',
      data = {},
      header = {}
    } = options;

    return new Promise((resolve, reject) => {
      this.retryCount = 0;
      
      // 优先尝试 fetch + ReadableStream(真正的流式)
      if (typeof fetch === 'function' && typeof ReadableStream === 'function') {
        console.log('[SSE] 使用 fetch + ReadableStream 流式模式');
        this._connectFetch(url, { method, data, header }, resolve, reject);
      } else {
        console.log('[SSE] fetch/ReadableStream 不可用,降级到 uni.request');
        this._doRequest({ url, method, body: data, headers: header }, resolve, reject);
      }
    });
  }

  /**
   * 使用 fetch + ReadableStream 的SSE连接(H5/App通用)
   */
  async _connectFetch(url, { method, data, header }, resolve, reject) {
    try {
      const response = await fetch(url, {
        method: method,
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'text/event-stream',
          'Cache-Control': 'no-cache',
          ...header
        },
        body: JSON.stringify(data)
      });

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

      if (!response.body) {
        throw new Error('流式响应不支持');
      }

      const reader = response.body.getReader();
      let decoder;
      try {
        decoder = new TextDecoder();
      } catch (e) {
        decoder = {
          decode(arr) {
            return decodeURIComponent(escape(String.fromCharCode(...arr)));
          }
        };
      }
      let buffer = '';

      this.isConnected = true;
      this.onOpenCallback && this.onOpenCallback();
      resolve('SSE连接已建立');

      const readStream = async () => {
        try {
          while (true) {
            const { done, value } = await reader.read();

            if (done) {
              this.isConnected = false;
              this.onCloseCallback && this.onCloseCallback();
              break;
            }

            const chunk = decoder.decode(value, { stream: true });
            buffer += chunk;

            const lines = buffer.split('\n');
            buffer = lines.pop() || '';

            for (const line of lines) {
              if (line.trim()) {
                this._processSSELine(line);
              }
            }
          }
        } catch (error) {
          this.onErrorCallback && this.onErrorCallback(error);
        } finally {
          reader.releaseLock();
        }
      };

      readStream();
    } catch (error) {
      console.error('[SSE] fetch请求失败:', error);
      // fetch 失败时降级到 uni.request
      console.log('[SSE] 降级到 uni.request');
      this._doRequest({ url, method, body: data, headers: header }, resolve, reject);
    }
  }

  /**
   * 处理SSE数据行
   */
  _processSSELine(line) {
    // 兼容 data:token 和 data: token 两种格式
    if (line.startsWith('data:')) {
      const data = line.substring(5).trim();
      if (data === '[DONE]') {
        console.log('[SSE] 收到 [DONE] 信号');
        this.close();
        return;
      }
      
      try {
        const jsonData = JSON.parse(data);
        this.onMessageCallback && this.onMessageCallback({
          data: jsonData,
          raw: line
        });
      } catch (e) {
        this.onMessageCallback && this.onMessageCallback({
          data: data,
          raw: line
        });
      }
    } else if (line.trim()) {
      this.onMessageCallback && this.onMessageCallback({
        data: line,
        raw: line
      });
    }
  }

  /**
   * 执行请求并处理超时、心跳等(基于你项目的doRequest方法)
   */
  _doRequest({ body, url, headers, method }, resolve, reject) {
    console.log(`🔄 开始SSE连接,重试次数: ${this.retryCount}/${this.maxRetryCount}`);
    this.isFailed = false;

    // 设置请求超时
    if (this.timeout) {
      this.timeoutTimer = setTimeout(() => {
        this.close();
        this._retry({ body, url, headers, method }, resolve, reject);
        console.warn('⏰ SSE请求超时,主动断开连接');
      }, this.timeout);
    }

    this.requestTask = uni.request({
      url: url,
      method,
      header: {
        Accept: 'text/event-stream',
        ...headers,
      },
      data: body,
      enableChunked: true,
      responseType: 'arraybuffer',
      success: (res) => {
        console.log('[SSE] success回调触发,data类型:', typeof res?.data, res?.data instanceof ArrayBuffer ? 'ArrayBuffer' : '');
        if (!res) return;
        // Android: enableChunked 生效时,data 是 ArrayBuffer(已通过 onChunkReceived 逐块处理)
        if (res.data instanceof ArrayBuffer) {
          this._listener(res);
        } else if (typeof res.data === 'string') {
          // iOS/H5: enableChunked 不生效时,整个响应回来是字符串
          console.log('[SSE] 字符串响应,直接解析,长度:', res.data.length);
          this._processStringResponse(res.data);
        } else {
          console.warn('[SSE] 未知的res.data类型:', typeof res.data);
        }
      },
      fail: (error) => {
        console.error('[SSE] 请求失败:', error);
        this.onErrorCallback && this.onErrorCallback(error);
        this._retry({ body, url, headers, method }, resolve, reject);
        this.isFailed = true;
      },
      complete: () => {
        console.log('[SSE] complete回调触发,isFailed:', this.isFailed);
        this._clearTimers();
        if (!this.isFailed) {
          this.onCloseCallback && this.onCloseCallback();
        }
      },
    });

    // 仅在支持 onChunkReceived 的平台(Android)注册分块监听
    if (this.requestTask && typeof this.requestTask.onChunkReceived === 'function') {
      this.requestTask.onChunkReceived(this._listener.bind(this));
      console.log('[SSE] onChunkReceived 已注册(支持分块传输)');
    } else {
      console.log('[SSE] onChunkReceived 不可用,依赖 success 回调处理响应');
    }
    this.isConnected = true;
    this.onOpenCallback && this.onOpenCallback(this.requestTask);
    resolve && resolve('SSE连接已建立');

    // 设置心跳超时
    if (this.heartbeatTimeout) {
      this._resetHeartbeat({ body, url, headers, method }, resolve, reject);
    }
  }

  /**
   * 处理字符串格式的SSE响应(iOS/H5兼容)
   * 后端输出: data:token1

data:token2

data:[DONE]


   */
  _processStringResponse(fullText) {
    const lines = fullText.split('\n');
    for (const line of lines) {
      const trimmed = line.trim();
      if (trimmed) {
        this._processSSELine(trimmed);
      }
    }
  }

  /**
   * 数据监听回调,收到数据时重置心跳(基于你项目逻辑)
   */
  _listener({ data }) {
    if (this.heartbeatTimeout) {
      this._resetHeartbeat();
    }
    
    // 使用插件的数据解析器处理接收到的数据
    if (data && this.onChunk) {
      try {
        this.onChunk(new Uint8Array(data));
      } catch (error) {
        console.error('处理SSE数据块失败:', error);
        this.onErrorCallback && this.onErrorCallback(error);
      }
    }
  }

  /**
   * 重置心跳定时器
   */
  _resetHeartbeat(params, resolve, reject) {
    if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
    
    if (this.heartbeatTimeout) {
      this.heartbeatTimer = setTimeout(() => {
        console.warn('💔 心跳超时,主动断开连接');
        this.close();
        if (params) {
          this._retry(params, resolve, reject);
        }
      }, this.heartbeatTimeout);
    }
  }

  /**
   * 重试连接
   */
  _retry(params, resolve, reject) {
    if (this.retryCount < this.maxRetryCount) {
      this.retryCount++;
      const delay = DEFAULT_RETRY_INTERVAL * Math.pow(RETRY_BACKOFF_MULTIPLIER, this.retryCount - 1);
      console.log(`🔄 ${delay}ms后进行第${this.retryCount}次重试`);
      
      setTimeout(() => {
        this._doRequest(params, resolve, reject);
      }, delay);
    } else {
      console.error('❌ 达到最大重试次数,连接失败');
      const error = new Error('达到最大重试次数');
      this.onErrorCallback && this.onErrorCallback(error);
      reject && reject(error);
    }
  }

  /**
   * 清除所有定时器
   */
  _clearTimers() {
    if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
    if (this.timeoutTimer) clearTimeout(this.timeoutTimer);
  }

  /**
   * 设置消息接收回调
   */
  onMessage(callback) {
    this.onMessageCallback = callback;
  }

  /**
   * 设置错误回调
   */
  onError(callback) {
    this.onErrorCallback = callback;
  }

  /**
   * 设置连接打开回调
   */
  onOpen(callback) {
    this.onOpenCallback = callback;
  }

  /**
   * 设置连接关闭回调
   */
  onClose(callback) {
    this.onCloseCallback = callback;
  }

  /**
   * 关闭连接
   */
  close() {
    this._clearTimers();
    if (this.requestTask) {
      this.requestTask.offChunkReceived && this.requestTask.offChunkReceived(this._listener);
      this.requestTask.abort && this.requestTask.abort();
    }
    this.isConnected = false;
    this.onCloseCallback && this.onCloseCallback();
  }

  /**
   * 获取连接状态
   */
  getReadyState() {
    return this.isConnected ? 1 : 0; // 1: OPEN, 0: CLOSED
  }
}

/**
 * 创建SSE客户端实例
 * @param {Object} options 配置选项
 * @returns {SSEClient} SSE客户端实例
 */
export function createSSEClient(options = {}) {
  return new SSEClient(options);
}

/**
 * 快速发起流式请求
 * @param {string} url 请求地址
 * @param {Object} options 请求配置
 * @returns {Promise<SSEClient>} SSE客户端实例
 */
export function streamRequest(url, options = {}) {
  const client = createSSEClient(options.clientOptions);
  
  return new Promise((resolve, reject) => {
    client.connect(url, {
      method: options.method || 'POST',
      data: options.data || {},
      header: options.headers || {}
    }).then(() => {
      resolve(client);
    }).catch(reject);
  });
}

// 兼容你项目中的ChatSSEClient命名
export const ChatSSEClient = SSEClient;

// 默认导出
export default {
  createSSEClient,
  streamRequest,
  SSEClient,
  ChatSSEClient
};
js 复制代码
/**
 * SSE数据解析器
 */

/**
 * 从流中获取字节数据(H5环境使用)
 */
export async function getBytes(stream, onChunk) {
    const reader = stream.getReader();
    let result;
    while (!(result = await reader.read()).done) {
        onChunk(result.value);
    }
}

/**
 * 将数据块分解为行
 */
export function getLines(onLine) {
    let buffer;
    let position;
    let fieldLength;
    let discardTrailingNewline = false;
    
    return function onChunk(arr) {
        if (buffer === undefined) {
            buffer = arr;
            position = 0;
            fieldLength = -1;
        } else {
            buffer = concat(buffer, arr);
        }
        
        const bufLength = buffer.length;
        let lineStart = 0;
        
        while (position < bufLength) {
            if (discardTrailingNewline) {
                if (buffer[position] === 10) {
                    lineStart = ++position;
                }
                discardTrailingNewline = false;
            }
            
            let lineEnd = -1;
            for (; position < bufLength && lineEnd === -1; ++position) {
                switch (buffer[position]) {
                    case 58: // ':'
                        if (fieldLength === -1) {
                            fieldLength = position - lineStart;
                        }
                        break;
                    case 13: // '\r'
                        discardTrailingNewline = true;
                    case 10: // '\n'
                        lineEnd = position;
                        break;
                }
            }
            
            if (lineEnd === -1) {
                break;
            }
            
            onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
            lineStart = position;
            fieldLength = -1;
        }
        
        if (lineStart === bufLength) {
            buffer = undefined;
        } else if (lineStart !== 0) {
            buffer = buffer.subarray(lineStart);
            position -= lineStart;
        }
    };
}

/**
 * 解析SSE消息
 */
export function getMessages(onId, onRetry, onMessage) {
    let message = newMessage();
    let decoder;

    // 安全初始化解码器,App端TextDecoder可能不可用
    try {
        decoder = new TextDecoder();
    } catch (e) {
        decoder = {
            decode(arraybuffer) {
                return decodeURIComponent(escape(String.fromCharCode(...arraybuffer)))
            }
        };
    }

    return function onLine(line, fieldLength) {
        if (line.length === 0) {
            // 空行表示消息结束
            onMessage && onMessage(message);
            message = newMessage();
        } else if (fieldLength > 0) {
            const field = decoder.decode(line.subarray(0, fieldLength));
            const valueOffset = fieldLength + (line[fieldLength + 1] === 32 ? 2 : 1);
            const value = decoder.decode(line.subarray(valueOffset));
            
            switch (field) {
                case 'data':
                    message.data = message.data
                        ? message.data + '\n' + value
                        : value;
                    break;
                case 'event':
                    message.event = value;
                    break;
                case 'id':
                    onId && onId(message.id = value);
                    break;
                case 'retry':
                    const retry = parseInt(value, 10);
                    if (!isNaN(retry)) {
                        onRetry && onRetry(message.retry = retry);
                    }
                    break;
                default:
                    // 未知字段,尝试解析为普通数据
                    const msg = decoder.decode(line, { stream: true });
                    message.data = msg;
                    onMessage && onMessage(message);
                    break;
            }
        } else {
            // 没有字段分隔符的行,直接作为数据
            try {
                const msg = decoder.decode(line, { stream: true });
                message.data = msg;
                onMessage && onMessage(message);
            } catch (e) {
                console.warn('解析SSE行失败:', e);
            }
        }
    };
}

/**
 * 连接两个Uint8Array
 */
function concat(a, b) {
    const res = new Uint8Array(a.length + b.length);
    res.set(a);
    res.set(b, a.length);
    return res;
}

/**
 * 创建新的SSE消息对象
 */
function newMessage() {
    return {
        data: '',
        event: '',
        id: '',
        retry: undefined,
    };
}

然后我封装了一个文件chat.ts来封装这些内容,直接在页面中使用:

ts 复制代码
import request from '@/utils/request'
import { getApiUrl } from '@/config'
import { useUserStore } from '@/stores'
// @ts-ignore
import { createSSEClient } from '../../../components/js_sdk/index.js'

type ChatMessage = Api.Ai.ChatMessage

/**
 * AI 对话(普通模式,等待完整回复)
 * @param messages 消息列表
 */
export function fetchAiChat(messages: ChatMessage[]) {
  return request.post<string>({
    url: '/ai/chat',
    data: messages
  })
}

/**
 * AI 流式对话(SSE)
 * @param messages 消息列表
 * @param onToken 收到每个token时的回调
 * @param onComplete 流式完成回调
 * @param onError 错误回调
 */
export function fetchAiChatStream(
  messages: ChatMessage[],
  onToken: (token: string) => void,
  onComplete: () => void,
  onError: (error: any) => void
) {
  const baseUrl = getApiUrl()
  const userStore = useUserStore()
  const token = userStore?.accessToken || ''
  const url = `${baseUrl}/ai/chat/stream`

  console.log('[AI Stream] 请求URL:', url)
  console.log('[AI Stream] token存在:', !!token)

  const client = createSSEClient({
    timeout: 120000,
    heartbeatTimeout: 60000,
    maxRetryCount: 1
  })

  client.onMessage((event: any) => {
    try {
      const data = event.data
      // 后端通过 SseEmitter.send(token, TEXT_PLAIN) 发送纯文本
      // uni-sse-client 插件解析后 data 为字符串(token 文本)
      if (typeof data === 'string' && data && data !== '[DONE]') {
        console.log('[AI Stream] 收到token:', data.substring(0, 20))
        onToken(data)
      }
    } catch (e) {
      console.error('[AI Stream] 解析消息失败:', e)
    }
  })

  client.onError((error: any) => {
    console.error('[AI Stream] 错误:', error)
    onError(error)
  })

  client.onClose(() => {
    console.log('[AI Stream] 连接关闭')
    onComplete()
  })

  client.connect(url, {
    method: 'POST',
    data: messages,
    header: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    }
  }).catch((err: any) => {
    console.error('[AI Stream] 连接失败:', err)
    onError(err)
  })

  return client
}

页面中调用:

ts 复制代码
/** 发送消息 */
const onSend = async () => {
  const text = inputText.value.trim()
  if (!text || isLoading.value) return

  // 添加用户消息
  messages.value.push({ role: 'user', content: text })
  inputText.value = ''
  scrollToBottom()

  // 添加 AI 占位消息(loading 状态)
  messages.value.push({ role: 'assistant', content: '', loading: true })
  scrollToBottom()
  isLoading.value = true

  try {
    // 构造消息列表(去掉 loading 状态的占位消息)
    const chatMessages: ChatMessage[] = messages.value
      .filter(m => m.content)
      .map(m => ({ role: m.role, content: m.content }))

    // 获取最后一条 AI 消息的引用
    const lastMsg = messages.value[messages.value.length - 1]

    console.log('[AI] 开始流式请求,消息数:', chatMessages.length)

    // 使用流式接口实现打字机效果
    fetchAiChatStream(
      chatMessages,
      // 收到每个 token 时的回调
      (token: string) => {
        if (lastMsg && lastMsg.role === 'assistant') {
          // 首次收到 token,关闭 loading 动画
          if (lastMsg.loading) {
            lastMsg.loading = false
          }
          lastMsg.content += token
          scrollToBottom()
        }
      },
      // 流式完成回调
      () => {
        console.log('[AI] 流式完成')
        if (lastMsg && lastMsg.role === 'assistant') {
          lastMsg.loading = false
          // 如果没有任何内容,显示提示
          if (!lastMsg.content) {
            lastMsg.content = '抱歉,暂时无法回答您的问题。'
          }
        }
        isLoading.value = false
        scrollToBottom()
      },
      // 错误回调
      (error) => {
        console.error('[AI] 流式请求失败:', error)
        if (lastMsg && lastMsg.role === 'assistant') {
          lastMsg.content = '抱歉,AI服务暂时不可用,请稍后再试。'
          lastMsg.loading = false
        }
        isLoading.value = false
        scrollToBottom()
      }
    )
  } catch (error: any) {
    console.error('[AI] 对话异常:', error)
    const lastMsg = messages.value[messages.value.length - 1]
    if (lastMsg && lastMsg.role === 'assistant') {
      lastMsg.content = '抱歉,AI服务暂时不可用,请稍后再试。'
      lastMsg.loading = false
    }
    isLoading.value = false
    scrollToBottom()
  }
}

实际效果情况

也是可以实现 SSE 流式输出效果。

九、总结

在 uni-app 中实现 SSE 的过程,我个人最大的感受其实不是 " XMLHttpRequest可以提供解决 SSE" ,而是:

能解决是一回事,好不好维护又是另一回事。

在 Web环境下,SSE 本身就是一个非常成熟、使用非常简单的技术,直接使用 EventSource 就可以完成服务端的流式推送。

但是到了 uniapp的 App 端,由于运行环境和传统浏览器存在差异,一些 Web 端非常自然实现方式,在移动端并不能直接照搬,即使能实现,实现的效果非常不好,很影响用户体验。

所以从我个人的实际开发体验来看:

如果一个功能对实时通信、流式传输的要求比较高,那么在选择技术方案的时候,最好一开始就把移动端平台能力考虑进去,而不是 Web 能跑以后再想办法适配 App。

如果项目确实需要在 uni-app App 中实现类似 AI 流式输出的功能,我个人会更倾向于:

通过使用 WebSocket 实现效果

或者

如果移动端才是项目核心,那么可以重新评估是否真的适合使用 uniapp。

当然,这并不是说:

uni-app 不能做 SSE。

而是:

技术上能实现,不代表工程上一定是最优选择。

并且在这里希望 uni-app 官方能意识到这个问题,并及时在后面给我们这些开发者提供一个明确的解决方案。

这也是这次踩坑以后,我自己比较深的一点感受:

技术选型不能只看"能不能实现",还要看"实现以后是否舒服、是否稳定、是否容易维护"。

毕竟真实项目开发中,真正让程序员难受的,往往不是第一次把功能做出来,而是半年以后自己回来维护这段代码的时候。 😅

相关推荐
考虑考虑1 小时前
elasticSearch中的element_type
运维·后端·elasticsearch
掘金者阿豪2 小时前
飞牛部署 Wallos:把长期订阅和周期支出放进自己的 NAS 管理
后端
JMchen2 小时前
第 2 篇|Kotlin 进阶 —— 集合、循环与条件表达式
android·kotlin
黑科技iOS上架2 小时前
iOS市场截图尺寸规范的生成器
ios·审核
ly76892 小时前
Spring 异步编程的隐藏风险:@Async 线程池耗尽与异常处理详解
java·后端·spring·异常处理·线程池·任务拒绝
用户8181870627462 小时前
第23章 热点Key问题排查与解决:大促场景经典坑
java·后端
geovindu2 小时前
go: Task Scheduler
开发语言·后端·golang
律宏阔2 小时前
Android 自定义 Launcher 加载第三方 App Widget
android
逻辑克2 小时前
iPhone Duo 适配 Drag and Drop 实现方案
ios·程序员