在开发 AI 对话、智能组卷等场景时,由于后端的响应时间可能很长,我们通常会使用 SSE (Server-Sent Events) 流式输出,前端通过接收不断传来的数据块(chunk)实现"打字机"效果或者实时更新进度条。如图效果

接口流式输出返回如图

接口返回完整数据:

然而,在 uni-app 开发中,很多开发者会遇到一个巨坑:H5 端一切正常,但打包成 App (Android/iOS) 后,原本该一点点出来的文字,死活憋到了十几秒请求彻底结束后才"啪"地一下全部弹出来。
这篇文章将详细剖析这个问题的原因,并提供一套极其优雅且支持多端共存的终极解决方案。
参考文章: uni-app App 端不支持 SSE?用 renderjs + XHR 流式解析实现稳定输出_uniapp调用接口返回sse的时候 需要用renderjs吗-CSDN博客
一、 为什么 App 端无法实时接收流式数据?
在 uni-app 中,我们通常使用 uni.request({ enableChunked: true }) 来接收流式请求。
- H5/小程序端 :底层使用的是标准的 XMLHttpRequest/fetch 或微信原生网络库,对
chunked支持良好。 - App 端 (APP-PLUS) :底层走的是 Android 的 OkHttp 或者是 iOS 的 NSURLSession。出于某些默认策略或者网关代理配置(例如缺少了
X-Accel-Buffering: no等响应头),底层的原生网络库会将接收到的流数据进行强制缓冲 。它会认为"这只是一次普通的 HTTP 请求",只有等服务器彻底断开连接时,它才一次性把所有数据抛给onChunkReceived。
这直接导致了我们在前端写的解析逻辑彻底失效,AI 打字机效果变成了"卡顿 -> 秒出全文"。
二、 核心破局思路:RenderJS
既然"原生层"的网络请求走不通,那我们能不能回到"浏览器层"去发请求?
能!利用 uni-app 的 renderjs 特性。
renderjs是运行在视图层(Webview)的一段 JavaScript。它能够完全访问浏览器原生的 DOM 和 BOM API。
解决方案的核心链路如下:
- 绕过原生拦截 :在
renderjs中直接使用标准的浏览器 API ------XMLHttpRequest发起请求。 - 捕获增量切片 :利用
XMLHttpRequest.onprogress事件,它是实时触发的,能够精准拿到当前已接收到的responseText。 - 通信桥接 :通过
ownerInstance.callMethod,将切片数据源源不断地传回逻辑层(Vue 层),让 Vue 继续干它擅长的数据绑定和渲染。 - 多端兼容 :将其封装成一个独立的
<AppSseBridge>组件,并利用#ifdef APP-PLUS做到对原有 H5/小程序代码的零污染。
三、 完整代码实现
1. 封装桥接组件 (AppSseBridge.vue)
在 src/components/common/AppSseBridge.vue 中创建如下代码。这个组件是无 UI 的,纯粹作为逻辑层和 Webview 层的通信桥梁
js
<template>
<view
id="sse-bridge"
class="app-sse-bridge"
:prop="bridgeData"
:change:prop="sseRender.handleProp"
></view>
</template>
<script>
export default {
name: "AppSseBridge",
props: {
// 逻辑层传入的连接参数(包含 URL, method, token, data 等)
bridgeData: {
type: Object,
default: () => ({}),
},
},
emits: ["chunk", "done", "error"],
methods: {
// 接收 Webview 层传回的增量数据
onSSEChunk(payload) {
this.$emit("chunk", payload);
},
onSSEDone(payload) {
this.$emit("done", payload);
},
onSSEError(payload) {
this.$emit("error", payload);
},
},
};
</script>
<script module="sseRender" lang="renderjs">
export default {
data() {
return {
_currentXHR: null,
_cursor: 0,
_latestSeq: 0,
_stopped: false,
};
},
methods: {
// 监听逻辑层 prop 变化,一旦触发 connect,就开始发请求
handleProp(newVal, oldVal, ownerInstance) {
const payload = newVal || {};
const seq = Number(payload.seq || 0);
if (seq <= this._latestSeq) return;
this._latestSeq = seq;
if (payload.type === "disconnect") {
this.closeConnection();
return;
}
if (payload.type !== "connect") return;
this.setupSSEConnection(payload, ownerInstance);
},
setupSSEConnection(payload, ownerInstance) {
this.closeConnection();
this._stopped = false;
this._cursor = 0; // 初始化游标
const xhr = new XMLHttpRequest();
this._currentXHR = xhr;
const method = payload.method || "POST";
xhr.open(method, payload.url, true);
xhr.setRequestHeader("Content-Type", "application/json");
// 动态注入 Header (如 Token)
const headers = payload.headers || {};
Object.keys(headers).forEach((key) => {
const value = headers[key];
if (value !== undefined && value !== null && value !== "") {
xhr.setRequestHeader(key, value);
}
});
xhr.timeout = Number(payload.timeout || 180000);
// 核心所在:监听 onprogress,实时拆解 responseText
xhr.onprogress = () => {
if (this._stopped) return;
const responseText = xhr.responseText || "";
const chunkText = responseText.slice(this._cursor);
this._cursor = responseText.length;
if (!chunkText) return;
// 回传给逻辑层
if (ownerInstance && typeof ownerInstance.callMethod === "function") {
ownerInstance.callMethod("onSSEChunk", { text: chunkText });
}
};
xhr.onreadystatechange = () => {
if (xhr.readyState !== 4 || this._stopped) return;
this._currentXHR = null;
if (ownerInstance && typeof ownerInstance.callMethod === "function") {
ownerInstance.callMethod("onSSEDone", { statusCode: xhr.status, responseText: xhr.responseText });
}
};
xhr.onerror = () => {
if (this._stopped) return;
this._currentXHR = null;
if (ownerInstance && typeof ownerInstance.callMethod === "function") {
ownerInstance.callMethod("onSSEError", { message: "APP renderjs 请求失败" });
}
};
if (method === "POST" && payload.data) {
xhr.send(JSON.stringify(payload.data));
} else {
xhr.send();
}
},
closeConnection() {
this._stopped = true;
this._cursor = 0;
if (this._currentXHR) {
this._currentXHR.abort();
this._currentXHR = null;
}
},
},
};
</script>
<style scoped>
/* 桥接组件不需要展示在界面上 */
.app-sse-bridge {
width: 0;
height: 0;
overflow: hidden;
}
</style>
2. 页面接入与多端优雅降级
在你实际发请求的页面(比如 AI 对话页,或者组卷 Loading 页)中引入该组件,并通过条件编译实现双轨制:H5/小程序走常规方案,App走桥接方案。
模板部分 (Template):
html
<template>
<view class="page">
<!-- 仅在 APP 端渲染桥接组件 -->
<!-- #ifdef APP-PLUS -->
<AppSseBridge
:bridge-data="appStreamBridgePayload"
@chunk="handleAppStreamChunk"
@done="handleAppStreamDone"
@error="handleAppStreamError"
/>
<!-- #endif -->
<!-- 你的页面内容 -->
<view>{{ aiText }}</view>
</view>
</template>
逻辑部分 (Script):
js
<script setup>
import { ref } from 'vue';
// 1. 引入桥接组件
import AppSseBridge from '@/src/components/common/AppSseBridge.vue';
// 2. 准备桥接组件状态
const appStreamBridgePayload = ref({ seq: 0, type: "idle" });
let bridgeResolve = null;
let bridgeReject = null;
// 3. 接收 App 端抛出的数据块并解析
const handleAppStreamChunk = (payload) => {
const text = payload.text || '';
// 按照你的业务逻辑处理 text
// 比如拼接 AI 回复,或者是正则解析 event/data
console.log("【实时收到流数据】", text);
};
const handleAppStreamDone = (payload) => {
if (bridgeResolve) bridgeResolve(payload.responseText || '');
};
const handleAppStreamError = (payload) => {
if (bridgeReject) bridgeReject(new Error(payload.message || '网络请求失败'));
};
// 4. 发起请求的核心方法
async function fetchAiData(requestParams) {
let res;
// ==============================
// 非 App 端:使用 uni.request
// ==============================
// #ifndef APP-PLUS
res = await uni.request({
url: 'https://api.yourdomain.com/stream',
method: 'POST',
data: requestParams,
enableChunked: true, // 开启流式接收
success: (r) => { /* 处理结果 */ }
});
// 绑定原生 onChunkReceived 事件 (按实际业务编写)
// #endif
// ==============================
// App 端:触发 AppSseBridge
// ==============================
// #ifdef APP-PLUS
res = await new Promise((resolve, reject) => {
bridgeResolve = resolve;
bridgeReject = reject;
// seq 自增是关键!这会触发 renderjs 中的 change:prop 监听
const seq = Number(appStreamBridgePayload.value.seq || 0) + 1;
appStreamBridgePayload.value = {
seq,
type: "connect",
url: 'https://api.yourdomain.com/stream',
method: 'POST',
data: requestParams,
headers: {
token: uni.getStorageSync('token') || ''
},
timeout: 180000
};
});
// #endif
// 请求完成后的公共处理逻辑
console.log("全部结束", res);
}
</script>
四、 总结与原理回顾
这种方案之所以能被称作"终极指南",是因为它做到了以下几点:
- 零依赖:不需要引入任何第三方库。
- 纯前端解决:即使后端人员不配合修改响应头,前端也能自力更生解决 App 流被强制合并的问题。
- 架构解耦 :将所有恶心的 Webview 交互脏代码圈在了
AppSseBridge这一个组件中。对于业务侧来说,它就像普通的 Promise 一样容易调用。 - 性能极佳 :通过
_cursor切片提取增量文本,避免了反复处理巨大的完整文本,内存占用和 CPU 消耗极低。
如果你的项目中正在做 AI 对话流或者需要 SSE,快把这个 AppSseBridge 加入你的代码库吧!