一端运行,多端踩坑:uni-app APP 端 SSE 流式输出只能靠 RenderJS
uni-app APP 端 SSE 流式输出解决方案:RenderJS + XHR
在 uni-app 开发中,做APP 端的AI大模型流式,实现 SSE(Server-Sent Events)流式输出问题。本文记录了从发现问题到最终解决的完整过程,希望能帮到同样踩坑的开发者。
背景
项目是一个 AI 聊天应用,后端使用 Spring Boot + Spring AI,AI 回复通过 SSE 流式推送到前端。前端使用 Vue 3 + uni-app,需要同时支持 H5 和 APP 两个平台。
H5 端使用 @microsoft/fetch-event-source 库,流式输出完美运行。但到了 APP 端,一切都变了。
问题:APP 端没有流式效果
在 APP 端运行时,AI 回复虽然能正常返回,但文本是一次性全部展示出来的,没有逐字出现的打字机效果。用户看到的是:等待几秒 → 一大段文字突然出现。
踩坑历程
第一坑:XMLHttpRequest is not a constructor
最初在 APP 端使用浏览器原生的 XMLHttpRequest 来接收 SSE 流:
// #ifndef H5
const xhr = new XMLHttpRequest() // 💥 报错:XMLHttpRequest is not a constructor
xhr.open('POST', url)
xhr.send()
// #endif
原因:uni-app APP 端的 JS 运行环境不是浏览器,没有全局的 XMLHttpRequest 构造函数。
第二坑:TextDecoder is not defined
尝试用 uni.request 的 enableChunked + onChunkReceived 方案,需要解码 ArrayBuffer:
const decoder = new TextDecoder('utf-8') // 💥 APP 端同样没有
原因:APP 端的 JS 引擎(V8/JSCore)不包含浏览器的 Web API。
第三坑:uni.request 的 enableChunked 无效
使用 uni-app 官方推荐的流式方案:
const reqTask = uni.request({
url: url,
method: 'POST',
enableChunked: true, // 开启分块传输
success(res) { /* ... */ }
})
reqTask.onChunkReceived((res) => {
// 💥 onChunkReceived 不是函数,或根本收不到数据
})
原因:enableChunked 在部分 Android 设备和 uni-app 版本上不可用,或者被 WebView 底层缓冲了。
第四坑:plus.net.XMLHttpRequest 缓冲整块响应
使用 HTML5+ 的原生 XHR:
const xhr = new plus.net.XMLHttpRequest()
xhr.open('POST', url)
xhr.onreadystatechange = () => {
// readyState=3 时 responseText 为空
// readyState=4 时数据一次性到达
}
日志证据:
[readyState: 3, responseLen: 0] ← 流已连接,但没有数据
[readyState: 3, responseLen: 930] ← 所有数据一次性到达
[readyState: 4, responseLen: 930] ← 请求完成
原因:Android WebView 的网络层对 HTTP 响应做了缓冲,readyState=3 阶段 responseText 为空,数据在连接关闭时才一次性释放。
第五坑:RenderJS + fetch + ReadableStream 依然缓冲
使用 RenderJS(运行在 WebView 的真实浏览器环境中)+ 现代 fetch API:
// RenderJS 中
const response = await fetch(url, opts)
const reader = response.body.getReader()
while (true) {
const { value, done } = await reader.read() // 💥 循环卡住,最后一次性返回
}
原因:部分 Android WebView(特别是较老版本的 Chromium)虽然表面支持 fetch + ReadableStream,但底层仍然把所有流数据缓冲到连接关闭后才一次性返回。
最终方案:RenderJS + XMLHttpRequest
核心思路:在 RenderJS 中使用古老的 XMLHttpRequest,监听 readyState=3 状态来获取增量数据。
核心思想是:将网络通信和流式数据处理的任务“外包”给 RenderJS 运行的视图层 WebView,因为它拥有一个完整的浏览器环境。然后,RenderJS 将处理好的数据片段通过特定机制传递回逻辑层(Service 层)进行业务处理和界面渲染。
为什么这个方案能行?
- RenderJS 运行在 WebView 的真实浏览器环境中,拥有完整的 Web API
XMLHttpRequest的readyState=3(LOADING 状态)是一种极度底层的监听方式- 只要 TCP 数据包到达网卡,
responseText就会变长,触发回调 - 通过字符串长度对比
substring(lastProcessedLen)截取增量片段 - 这种方式在任何 Android 版本和任何 WebView 上都能 100% 保证按块触发
架构设计
┌─────────────────────────────────────────┐
│ uni-app 架构 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 逻辑层 (JS) │ │ 视图层 (WV) │ │
│ │ │ │ │ │
│ │ Vue 组件 │◄───│ RenderJS │ │
│ │ (script │call│ (真实浏览器 │ │
│ │ setup) │Method 环境) │ │
│ │ │ │ │ │
│ │ - 消息列表 │ │ - XHR 请求 │ │
│ │ - AI 气泡 │ │ - 流式读取 │ │
│ │ - 滚动控制 │ │ - 增量解析 │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘
实现步骤
1. Template 中添加 RenderJS 绑定元素
<template>
<view class="chat-detail-page">
<!-- RenderJS 绑定元素 -->
<view
:sseConfig="sseConfig"
:change:sseConfig="renderScript.startSSE"
style="display: none;"
></view>
<!-- ... 其他 UI ... -->
</view>
</template>
:change:sseConfig 是 uni-app 的特殊语法,当 sseConfig 数据变化时,自动调用 RenderJS 的 startSSE 方法。
2. Vue 逻辑层(<script setup>)
import { getCurrentInstance } from 'vue'
// APP 端状态
const sseConfig = ref(null)
let currentAiMsg = null
let replyStartedFlag = false
// 启动流式请求
const startAiStream = async (url, body, aiMsg) => {
// #ifdef H5
// H5 端使用 fetchEventSource(省略)
// #endif
// #ifndef H5
currentAiMsg = aiMsg
replyStartedFlag = false
// 将配置传给 RenderJS,触发 :change 侦听器
sseConfig.value = {
url: url,
token: tokenManager.getAccessToken(),
body: body,
timestamp: Date.now() // 确保每次都能触发 change
}
// #endif
}
// RenderJS 回调:接收原始 SSE 字符片段
const handleSSERawChunkFromRenderJS = (chunk) => {
renderSseBuffer += chunk
// 按 SSE 协议的双换行拆包
const parts = renderSseBuffer.split(/\r?\n\r?\n/)
renderSseBuffer = parts.pop() || ''
parts.forEach(part => { /* 解析并更新 UI */ })
}
// RenderJS 回调:请求状态(完成/错误)
const handleSSEStatusFromRenderJS = (statusData) => {
// 处理完成或错误状态
}
// ⭐ 关键:通过 getCurrentInstance 将方法挂载到组件实例上
// Vue 3 <script setup> 的方法默认不暴露给外部
// callMethod 需要方法在组件实例上才能找到
const instance = getCurrentInstance()
if (instance && instance.proxy) {
instance.proxy.handleSSERawChunkFromRenderJS = handleSSERawChunkFromRenderJS
instance.proxy.handleSSEStatusFromRenderJS = handleSSEStatusFromRenderJS
}
3. RenderJS 模块(<script module="renderScript" lang="renderjs">)
export default {
methods: {
startSSE(newVal, oldVal, ownerInstance) {
if (!newVal || !newVal.url) return
const xhr = new XMLHttpRequest()
xhr.open('POST', newVal.url, true)
xhr.setRequestHeader('Authorization', 'Bearer ' + newVal.token)
xhr.setRequestHeader('Accept', 'text/event-stream')
if (newVal.body) {
xhr.setRequestHeader('Content-Type', 'application/json')
}
let lastProcessedLen = 0
xhr.onreadystatechange = () => {
// readyState=3: 数据正在流入
// readyState=4: 请求完成
if (xhr.readyState === 3 || xhr.readyState === 4) {
if (xhr.status !== 200 && xhr.readyState === 4) {
ownerInstance.callMethod('handleSSEStatusFromRenderJS',
{ status: 'error', code: xhr.status })
return
}
const currentText = xhr.responseText || ''
// 通过字符串长度差截取增量片段
const chunk = currentText.substring(lastProcessedLen)
lastProcessedLen = currentText.length
if (chunk) {
// 将增量片段传回 Vue 逻辑层
ownerInstance.callMethod('handleSSERawChunkFromRenderJS', chunk)
}
if (xhr.readyState === 4) {
ownerInstance.callMethod('handleSSEStatusFromRenderJS',
{ status: 'done', code: 200 })
}
}
}
xhr.onerror = () => {
ownerInstance.callMethod('handleSSEStatusFromRenderJS',
{ status: 'error', code: 500 })
}
xhr.send(newVal.body ? JSON.stringify(newVal.body) : null)
}
}
}
数据流转过程
1. 用户发送消息
↓
2. Vue 组件设置 sseConfig.value = { url, token, body, timestamp }
↓
3. Template 的 :change:sseConfig 检测到数据变化
↓
4. 调用 RenderJS 的 startSSE 方法
↓
5. RenderJS 创建 XHR 请求,监听 onreadystatechange
↓
6. readyState=3 时,通过 responseText.substring(lastProcessedLen) 截取增量
↓
7. ownerInstance.callMethod('handleSSERawChunkFromRenderJS', chunk)
↓
8. Vue 组件接收增量文本,解析 SSE 协议,更新消息列表
↓
9. 用户看到逐字出现的打字机效果

关键踩坑点总结
| 坑 | 原因 | 解决方案 |
|---|---|---|
XMLHttpRequest 不可用 |
APP 端 JS 环境没有浏览器 API | 使用 RenderJS(运行在 WebView 浏览器环境) |
TextDecoder 不可用 |
同上 | 手动 String.fromCharCode 转换或 RenderJS 内使用 |
enableChunked 无效 |
uni-app 底层 WebView 缓冲 | 不依赖 uni-app 网络层 |
plus.net.XMLHttpRequest 缓冲 |
Android WebView 网络层缓冲 | 放弃,改用 RenderJS |
fetch + ReadableStream 缓冲 |
部分 Chromium 版本的底层 Bug | 改用 XHR readyState=3 |
callMethod 调不到方法 |
Vue 3 <script setup> 不暴露方法 |
getCurrentInstance().proxy 手动挂载 |
为什么不直接用 WebSocket?
WebSocket 确实是另一种方案,但:
- 后端改造成本:需要额外实现 WebSocket 服务端,而 SSE 是现有的
- 协议差异:SSE 天然支持事件类型(
event: analysis、event: done),WebSocket 需要自定义协议 - HTTP 兼容性:SSE 基于标准 HTTP,兼容所有代理和网关;WebSocket 需要额外的升级握手
- 重连机制:SSE 内置自动重连,WebSocket 需要手动实现
写在最后
uni-app 的跨平台能力确实强大,但在 APP 端的网络流式处理上,存在不少底层环境的坑。RenderJS 是连接 Web 标准 API 和 uni-app 生态的桥梁,善用它可以绕过很多原生环境的限制。
核心原则:当 uni-app 的原生 API 做不到时,把任务交给 RenderJS 里的真实浏览器环境。
本文基于 uni-app 3.x + Vue 3 + Android 真机环境,不同版本可能存在差异。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)