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.requestenableChunked + onChunkReceived 方案,需要解码 ArrayBuffer:

const decoder = new TextDecoder('utf-8')  // 💥 APP 端同样没有

原因:APP 端的 JS 引擎(V8/JSCore)不包含浏览器的 Web API。

第三坑:uni.requestenableChunked 无效

使用 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
  • XMLHttpRequestreadyState=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 确实是另一种方案,但:

  1. 后端改造成本:需要额外实现 WebSocket 服务端,而 SSE 是现有的
  2. 协议差异:SSE 天然支持事件类型(event: analysisevent: done),WebSocket 需要自定义协议
  3. HTTP 兼容性:SSE 基于标准 HTTP,兼容所有代理和网关;WebSocket 需要额外的升级握手
  4. 重连机制:SSE 内置自动重连,WebSocket 需要手动实现

写在最后

uni-app 的跨平台能力确实强大,但在 APP 端的网络流式处理上,存在不少底层环境的坑。RenderJS 是连接 Web 标准 API 和 uni-app 生态的桥梁,善用它可以绕过很多原生环境的限制。

核心原则:当 uni-app 的原生 API 做不到时,把任务交给 RenderJS 里的真实浏览器环境


本文基于 uni-app 3.x + Vue 3 + Android 真机环境,不同版本可能存在差异。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐