【终极指南】前端方面解决 uni-app APP 端 SSE 流式请求被缓冲拦截、无法实时渲染的问题

在开发 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。

解决方案的核心链路如下

  1. 绕过原生拦截 :在 renderjs 中直接使用标准的浏览器 API ------ XMLHttpRequest 发起请求。
  2. 捕获增量切片 :利用 XMLHttpRequest.onprogress 事件,它是实时触发的,能够精准拿到当前已接收到的 responseText
  3. 通信桥接 :通过 ownerInstance.callMethod,将切片数据源源不断地传回逻辑层(Vue 层),让 Vue 继续干它擅长的数据绑定和渲染。
  4. 多端兼容 :将其封装成一个独立的 <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>

四、 总结与原理回顾

这种方案之所以能被称作"终极指南",是因为它做到了以下几点:

  1. 零依赖:不需要引入任何第三方库。
  2. 纯前端解决:即使后端人员不配合修改响应头,前端也能自力更生解决 App 流被强制合并的问题。
  3. 架构解耦 :将所有恶心的 Webview 交互脏代码圈在了 AppSseBridge 这一个组件中。对于业务侧来说,它就像普通的 Promise 一样容易调用。
  4. 性能极佳 :通过 _cursor 切片提取增量文本,避免了反复处理巨大的完整文本,内存占用和 CPU 消耗极低。

如果你的项目中正在做 AI 对话流或者需要 SSE,快把这个 AppSseBridge 加入你的代码库吧!

相关推荐
BG1 小时前
利用Codex GPT-5.5 基于extended_image新增图片透视变换功能
前端·flutter
小四的小六1 小时前
WebView 内存治理与稳定性实战:那些线上OOM教会我的事
前端·webview
ZC跨境爬虫2 小时前
跟着 MDN 学 HTML day_29:(动态构建与更新 DOM 树)
前端·javascript·ui·html·html5·媒体
编程技术手记2 小时前
html table布局平衡
前端·html
huoyueyi2 小时前
3D数字孪生项目 LCP 优化指南
前端·3d·几何学
陆业聪2 小时前
技术选型决策树:什么团队、什么项目该选什么框架 | 跨平台框架深度对决(4)
android·架构设计
uccs2 小时前
系统认知 Agent 六大支柱
agent·ai编程·claude
菜鸟小芯2 小时前
【腾讯位置服务开发者征文大赛】校园美食雷达 —— 基于 CodeBuddy + 腾讯 LBS 开发实战
前端·美食
搜狐技术产品小编20233 小时前
深度解析与业务实战:将 screenshot-to-code 改造为支持 React + Ant Design 的前端利器
前端·javascript·react.js·前端框架·ecmascript