一、前言
最近在开发一个移动端应用软件,需要快速做到跨平台效果,我就基于 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/usesse 或 react-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 官方能意识到这个问题,并及时在后面给我们这些开发者提供一个明确的解决方案。
这也是这次踩坑以后,我自己比较深的一点感受:
技术选型不能只看"能不能实现",还要看"实现以后是否舒服、是否稳定、是否容易维护"。
毕竟真实项目开发中,真正让程序员难受的,往往不是第一次把功能做出来,而是半年以后自己回来维护这段代码的时候。 😅