正文
在做 AI 问答、智能助手、知识库问答这类功能时,后端通常会使用 SSE 返回流式数据。前端需要边接收边渲染,实现类似 ChatGPT 的"打字机"效果。
但是 uni-app 同时跑 H5 和微信小程序时,不能只写一套代码。原因是:
H5 浏览器环境可以使用 EventSource 或 EventSourcePolyfill 直接监听 SSE 消息。
微信小程序环境不支持浏览器原生 EventSource,也不能直接操作 DOM,因此需要使用 uni.request 开启流式接收,通过 requestTask.onChunkReceived 获取 ArrayBuffer 数据块,再手动解码和解析 SSE。
一、H5 版:使用 EventSourcePolyfill
如果接口需要携带 token,请优先使用 event-source-polyfill,因为原生 EventSource 不支持自定义 header。
npm install event-source-polyfill marked
import { EventSourcePolyfill } from 'event-source-polyfill'
import { marked } from 'marked'
let eventSource = null
let rawText = ''
function connectH5SSE({ url, token, content, onMessage, onEnd, onError }) {
rawText = ''
const query = new URLSearchParams({
content,
type: 6,
soure: 'APP'
}).toString()
eventSource = new EventSourcePolyfill(`${url}?${query}`, {
headers: {
Authorization: token,
Accept: 'text/event-stream'
},
heartbeatTimeout: 60000
})
eventSource.addEventListener('message', event => {
try {
const data = JSON.parse(event.data)
if (data.event === 'message_end') {
closeH5SSE()
onEnd && onEnd()
return
}
if (data.event === 'message' && data.answer) {
rawText += data.answer
const html = marked(rawText)
onMessage && onMessage(html, rawText)
}
} catch (err) {
console.error('SSE 消息解析失败:', err)
}
})
eventSource.addEventListener('error', err => {
console.error('SSE 连接异常:', err)
closeH5SSE()
onError && onError(err)
})
}
function closeH5SSE() {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
页面里可以这样用:
connectH5SSE({
url: 'https://你的域名/biz/sse/livehoodaiqa/aiqastream',
token: 'Bearer xxx',
content: this.inputValue,
onMessage: html => {
this.messages[this.messageIndex].content = html
this.messages[this.messageIndex].loading = false
this.$nextTick(() => {
this.scrollToBottom()
})
},
onEnd: () => {
this.loading = false
},
onError: () => {
this.loading = false
}
})
二、小程序版:uni.request + onChunkReceived
微信小程序不能直接用浏览器的 EventSource,所以需要:
uni.request设置enableChunked: true- 通过
requestTask.onChunkReceived接收流式ArrayBuffer - 使用
TextDecoder / wx.createTextDecoder / 降级解码转字符串 - 按 SSE 协议用
\n\n拆分完整事件 - 解析
data:字段,更新页面内容
三、通用 ArrayBuffer 解码器
新建 utils/streamDecoder.js:
export function createStreamDecoder(encoding = 'utf-8') {
let decoder = null
if (typeof TextDecoder !== 'undefined') {
decoder = new TextDecoder(encoding)
} else if (typeof wx !== 'undefined' && wx.createTextDecoder) {
decoder = wx.createTextDecoder(encoding)
}
return {
decode(arrayBuffer) {
if (!arrayBuffer) return ''
if (decoder) {
try {
return decoder.decode(arrayBuffer, { stream: true })
} catch (err) {
console.warn('TextDecoder 解码失败,使用降级方案:', err)
}
}
return fallbackDecode(arrayBuffer)
}
}
}
function fallbackDecode(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer)
let result = ''
let i = 0
while (i < bytes.length) {
const byte1 = bytes[i++]
if (byte1 < 0x80) {
result += String.fromCharCode(byte1)
} else if (byte1 >= 0xc0 && byte1 < 0xe0) {
const byte2 = bytes[i++]
result += String.fromCharCode(((byte1 & 0x1f) << 6) | (byte2 & 0x3f))
} else if (byte1 >= 0xe0 && byte1 < 0xf0) {
const byte2 = bytes[i++]
const byte3 = bytes[i++]
result += String.fromCharCode(
((byte1 & 0x0f) << 12) |
((byte2 & 0x3f) << 6) |
(byte3 & 0x3f)
)
} else if (byte1 >= 0xf0) {
const byte2 = bytes[i++]
const byte3 = bytes[i++]
const byte4 = bytes[i++]
let codePoint =
((byte1 & 0x07) << 18) |
((byte2 & 0x3f) << 12) |
((byte3 & 0x3f) << 6) |
(byte4 & 0x3f)
codePoint -= 0x10000
result += String.fromCharCode(
0xd800 + (codePoint >> 10),
0xdc00 + (codePoint & 0x3ff)
)
}
}
return result
}
四、小程序 SSE 解析代码
import { marked } from 'marked'
import { createStreamDecoder } from '@/utils/streamDecoder.js'
export default {
data() {
return {
requestTask: null,
decoder: createStreamDecoder(),
buffer: '',
rawText: '',
messages: [],
messageIndex: 0,
loading: false,
scrollTop: 0
}
},
methods: {
startMiniProgramSSE(content) {
this.rawText = ''
this.buffer = ''
this.loading = true
const url = 'https://你的域名/biz/sse/livehoodaiqa/aiqastream'
this.requestTask = uni.request({
url,
method: 'GET',
enableChunked: true,
timeout: 0,
data: {
content,
type: 6,
soure: 'APP'
},
header: {
Authorization: 'Bearer xxx',
Accept: 'text/event-stream'
},
success: () => {
console.log('SSE 连接建立')
},
fail: err => {
console.error('SSE 连接失败:', err)
this.loading = false
}
})
this.requestTask.onChunkReceived(chunk => {
const text = this.decoder.decode(chunk.data)
this.buffer += text
this.parseSSEBuffer()
})
},
parseSSEBuffer() {
while (this.buffer.includes('\n\n')) {
const index = this.buffer.indexOf('\n\n')
const eventText = this.buffer.slice(0, index)
this.buffer = this.buffer.slice(index + 2)
this.parseSSEEvent(eventText)
}
},
parseSSEEvent(eventText) {
const lines = eventText.split('\n')
let dataText = ''
lines.forEach(line => {
if (line.startsWith('data:')) {
dataText += line.replace(/^data:\s?/, '')
}
})
if (!dataText) return
try {
const data = JSON.parse(dataText)
if (data.event === 'message_end') {
this.closeMiniProgramSSE()
return
}
if (data.event === 'message' && data.answer) {
this.rawText += data.answer
const html = marked(this.rawText)
this.messages[this.messageIndex].content = html
this.messages[this.messageIndex].loading = false
this.$nextTick(() => {
this.scrollToBottom()
})
}
} catch (err) {
console.error('SSE data 解析失败:', err, dataText)
}
},
closeMiniProgramSSE() {
this.loading = false
if (this.requestTask) {
this.requestTask.abort()
this.requestTask = null
}
},
scrollToBottom() {
const query = uni.createSelectorQuery().in(this)
query.select('.scroll-content').boundingClientRect(rect => {
if (rect) {
this.scrollTop = rect.height + Date.now()
}
}).exec()
}
}
}
五、打字机效果怎么实现
如果后端已经是一小段一小段返回,那么前端只需要不断追加:
this.rawText += data.answer
this.messages[this.messageIndex].content = marked(this.rawText)
如果后端一次返回较长内容,想做更细的逐字输出,可以加一个队列:
data() {
return {
typeQueue: [],
typingTimer: null,
rawText: ''
}
},
methods: {
pushTypeText(text) {
this.typeQueue.push(...text.split(''))
if (this.typingTimer) return
this.typingTimer = setInterval(() => {
if (!this.typeQueue.length) {
clearInterval(this.typingTimer)
this.typingTimer = null
return
}
this.rawText += this.typeQueue.shift()
this.messages[this.messageIndex].content = marked(this.rawText)
this.$nextTick(() => {
this.scrollToBottom()
})
}, 20)
}
}
收到流式数据时改成:
if (data.answer) {
this.pushTypeText(data.answer)
}
六、为什么 H5 和小程序要两套实现
H5 浏览器环境有完整的 Web API,可以使用 EventSource 监听 SSE,并且可以通过 DOM 处理滚动,比如 document.querySelector。
微信小程序不是浏览器环境,不支持原生 EventSource,DOM 操作能力也不同。流式数据需要通过 uni.request 的 enableChunked 和 onChunkReceived 接收,拿到的是 ArrayBuffer,所以还要自己做 UTF-8 解码和 SSE 协议解析。
简单来说:
H5:EventSourcePolyfill -> message 事件 -> JSON.parse -> 页面渲染
小程序:uni.request -> onChunkReceived -> ArrayBuffer 解码 -> buffer 拼接 -> SSE 解析 -> JSON.parse -> 页面渲染
七、踩坑总结
- 小程序必须设置
enableChunked: true,否则收不到分片数据。 onChunkReceived收到的是ArrayBuffer,不能直接当字符串处理。- SSE 不是每次 chunk 都是一条完整消息,需要用 buffer 缓存,再按
\n\n拆分。 - H5 原生
EventSource不支持自定义 header,需要 token 时建议用event-source-polyfill。 - 小程序滚动不要用 DOM,应该使用
uni.createSelectorQuery或scroll-view的scrollTop。 - Markdown 内容建议用
marked渲染,表格和代码块需要额外处理移动端横向滚动。 - 结束事件如
message_end一定要关闭连接,否则容易造成 loading 状态不结束或连接残留。
这套方案适合 uni-app 同时支持 H5 和微信小程序的 AI 流式问答场景。H5 用 EventSourcePolyfill 保持实现简单,小程序用 uni.request + onChunkReceived 兼容运行环境差异,两边最终都统一成"追加 answer、marked 渲染、滚动到底部"的页面更新逻辑。