uni-app App 端不支持 SSE?用 renderjs + XHR 流式解析实现稳定输出

背景

uni-app 项目里做 AI 对话流式输出时,H5 可以直接用浏览器能力处理流式响应,但 APP-PLUS 端经常遇到 SSE/流式兼容问题,典型表现是:

  • 接口已经持续返回 data: {...}
  • 但页面没有实时内容更新;

APP 端通过 renderjs 建立流式请求并增量解析,逻辑层只负责渲染

非 APP 端继续复用原有 uni.request 流式实现,做到多端共存。


方案核心

  1. 在 Vue 组件中放一个桥接节点::change:prop + :prop
  2. renderjs 里监听 prop 变化,执行 connect/disconnect
  3. 使用 XMLHttpRequest + onprogress 按增量切片解析 responseText
  4. 识别每一行 data:JSON.parse 后通过 ownerInstance.callMethod 回传逻辑层。
  5. 页面层接收 chunk,持续拼接 AI 文本并滚动到底部。

源码 1:桥接组件(AppSseBridge.vue)

vue 复制代码
<template>
  <view
    id="sse-bridge"
    class="app-sse-bridge"
    :prop="bridgeData"
    :change:prop="sseRender.handleProp"
  />
</template>

<script>
export default {
  name: "AppSseBridge",
  props: {
    bridgeData: {
      type: Object,
      default: () => ({}),
    },
  },
  emits: ["chunk", "done", "error"],
  methods: {
    onSSEData(payload) {
      this.$emit("chunk", payload || {});
    },
    onSSEDone(payload) {
      this.$emit("done", payload || {});
    },
    onSSEError(payload) {
      this.$emit("error", payload || {});
    },
  },
};
</script>

<script module="sseRender" lang="renderjs" src="./AppSseBridge.renderjs.js"></script>

<style scoped>
.app-sse-bridge {
  width: 0;
  height: 0;
  overflow: hidden;
}
</style>

源码 2:renderjs 流式实现(AppSseBridge.renderjs.js)

js 复制代码
function toFormUrlEncoded(data = {}) {
  return Object.keys(data)
    .map(
      (key) =>
        `${encodeURIComponent(key)}=${encodeURIComponent(data[key] ?? "")}`,
    )
    .join("&");
}

export default {
  data() {
    return {
      _currentXHR: null,
      _buffer: "",
      _cursor: 0,
      _latestSeq: 0,
      _stopped: false,
    };
  },
  methods: {
    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 || this.$ownerInstance);
    },

    setupSSEConnection(payload, ownerInstance) {
      this.closeConnection();
      this._stopped = false;
      this._buffer = "";
      this._cursor = 0;

      const xhr = new XMLHttpRequest();
      this._currentXHR = xhr;

      xhr.open("POST", payload.url, true);
      xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");

      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);
      xhr.responseType = "text";

      xhr.onprogress = () => {
        if (this._stopped) return;

        const responseText = xhr.responseText || "";
        const chunkText = responseText.slice(this._cursor);
        this._cursor = responseText.length;
        if (!chunkText) return;

        this._buffer += chunkText;
        const lines = this._buffer.split(/\r?\n/);
        this._buffer = lines.pop() || "";
        lines.forEach((line) => this.parseSSELine(line, ownerInstance));
      };

      xhr.onreadystatechange = () => {
        if (xhr.readyState !== 4 || this._stopped) return;
        this._currentXHR = null;
        if (ownerInstance && typeof ownerInstance.callMethod === "function") {
          ownerInstance.callMethod("onSSEDone", { statusCode: xhr.status });
        }
      };

      xhr.onerror = () => {
        if (this._stopped) return;
        this._currentXHR = null;
        if (ownerInstance && typeof ownerInstance.callMethod === "function") {
          ownerInstance.callMethod("onSSEError", {
            message: "APP renderjs 请求失败",
          });
        }
      };

      xhr.ontimeout = () => {
        if (this._stopped) return;
        this._currentXHR = null;
        if (ownerInstance && typeof ownerInstance.callMethod === "function") {
          ownerInstance.callMethod("onSSEError", {
            message: "APP renderjs 请求超时",
          });
        }
      };

      xhr.send(
        toFormUrlEncoded({
          askDtoString: JSON.stringify(payload.askDto || {}),
        }),
      );
    },

    parseSSELine(line, ownerInstance) {
      const text = String(line || "").trim();
      if (!text || !text.startsWith("data:")) return;

      const raw = text.slice(5).trim();
      if (!raw || raw === "[DONE]") return;

      try {
        const data = JSON.parse(raw);
        if (ownerInstance && typeof ownerInstance.callMethod === "function") {
          ownerInstance.callMethod("onSSEData", data);
        }
      } catch (error) {
        if (ownerInstance && typeof ownerInstance.callMethod === "function") {
          ownerInstance.callMethod("onSSEError", {
            message: "APP renderjs 解析失败",
          });
        }
      }
    },

    closeConnection() {
      this._stopped = true;
      this._buffer = "";
      this._cursor = 0;
      if (this._currentXHR) {
        this._currentXHR.abort();
        this._currentXHR = null;
      }
    },
  },
};

源码 3:页面接入方式(index.vue 关键片段)

vue 复制代码
<!-- #ifdef APP-PLUS -->
<AppSseBridge
  :bridge-data="appStreamBridgePayload"
  @chunk="handleAppStreamChunk"
  @done="handleAppStreamDone"
  @error="handleAppStreamError"
/>
<!-- #endif -->
js 复制代码
const appStreamBridgePayload = ref({ seq: 0, type: "idle" });
let currentAiMessage = null;

const handleSendText = (text) => {
  // ... 省略前置校验与入列 user/assistant 消息
  const seq = Number(appStreamBridgePayload.value.seq || 0) + 1;
  appStreamBridgePayload.value = {
    seq,
    type: "connect",
    url: `${API_BASE_URL}/xxx/xxx/xxxx`,
    headers: {
      Authorization: getToken() ? `Bearer ${getToken()}` : "",
    },
    askDto: streamParams,
    timeout: 180000,
  };
};

const handleAppStreamChunk = (chunk) => {
  if (!currentAiMessage || !isSending.value) return;
  if (chunk.chatId) currentChatId.value = chunk.chatId;
  if (chunk.thinkingMsg) currentAiMessage.thinking += chunk.thinkingMsg;
  if (chunk.msg) currentAiMessage.content += chunk.msg;
  if (chunk.finishReason === "stop") {
    isSending.value = false;
    currentAiMessage = null;
  }
};

相关推荐
恋猫de小郭2 小时前
Flutter 的 build_runner 已经今非昔比,看看 build_runner 2.13 有什么特别?
android·前端·flutter
yuhaiqiang2 小时前
AI 正在偷走大家的独立思考能力……
前端·后端·面试
不会写DN2 小时前
[特殊字符] JS Date 对象8大使用场景
开发语言·前端·javascript
WeirdoPrincess2 小时前
iOS 打包签名资料准备指南(HBuilderX / uni-app)
ios·uni-app
雨季mo浅忆2 小时前
el-upload二次封装带表格校验组件
javascript·vue2
bearpping10 小时前
Nginx 配置:alias 和 root 的区别
前端·javascript·nginx
@大迁世界10 小时前
07.React 中的 createRoot 方法是什么?它具体如何运作?
前端·javascript·react.js·前端框架·ecmascript
January120711 小时前
VBen Admin Select 选择框选中后仍然显示校验错误提示的解决方案
前端·vben
. . . . .11 小时前
前端测试框架:Vitest
前端