背景
在 uni-app 项目里做 AI 对话流式输出时,H5 可以直接用浏览器能力处理流式响应,但 APP-PLUS 端经常遇到 SSE/流式兼容问题,典型表现是:
- 接口已经持续返回
data: {...}; - 但页面没有实时内容更新;
APP 端通过 renderjs 建立流式请求并增量解析,逻辑层只负责渲染 。
非 APP 端继续复用原有 uni.request 流式实现,做到多端共存。
方案核心
- 在 Vue 组件中放一个桥接节点:
:change:prop + :prop。 - 在
renderjs里监听 prop 变化,执行connect/disconnect。 - 使用
XMLHttpRequest + onprogress按增量切片解析responseText。 - 识别每一行
data:,JSON.parse后通过ownerInstance.callMethod回传逻辑层。 - 页面层接收 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;
}
};