uni-app 实现 AI 流式问答:H5 EventSource 与微信小程序 onChunkReceived 的兼容方案

正文

在做 AI 问答、智能助手、知识库问答这类功能时,后端通常会使用 SSE 返回流式数据。前端需要边接收边渲染,实现类似 ChatGPT 的"打字机"效果。

但是 uni-app 同时跑 H5 和微信小程序时,不能只写一套代码。原因是:

H5 浏览器环境可以使用 EventSourceEventSourcePolyfill 直接监听 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,所以需要:

  1. uni.request 设置 enableChunked: true
  2. 通过 requestTask.onChunkReceived 接收流式 ArrayBuffer
  3. 使用 TextDecoder / wx.createTextDecoder / 降级解码 转字符串
  4. 按 SSE 协议用 \n\n 拆分完整事件
  5. 解析 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.requestenableChunkedonChunkReceived 接收,拿到的是 ArrayBuffer,所以还要自己做 UTF-8 解码和 SSE 协议解析。

简单来说:

H5:EventSourcePolyfill -> message 事件 -> JSON.parse -> 页面渲染

小程序:uni.request -> onChunkReceived -> ArrayBuffer 解码 -> buffer 拼接 -> SSE 解析 -> JSON.parse -> 页面渲染


七、踩坑总结

  1. 小程序必须设置 enableChunked: true,否则收不到分片数据。
  2. onChunkReceived 收到的是 ArrayBuffer,不能直接当字符串处理。
  3. SSE 不是每次 chunk 都是一条完整消息,需要用 buffer 缓存,再按 \n\n 拆分。
  4. H5 原生 EventSource 不支持自定义 header,需要 token 时建议用 event-source-polyfill
  5. 小程序滚动不要用 DOM,应该使用 uni.createSelectorQueryscroll-viewscrollTop
  6. Markdown 内容建议用 marked 渲染,表格和代码块需要额外处理移动端横向滚动。
  7. 结束事件如 message_end 一定要关闭连接,否则容易造成 loading 状态不结束或连接残留。

这套方案适合 uni-app 同时支持 H5 和微信小程序的 AI 流式问答场景。H5 用 EventSourcePolyfill 保持实现简单,小程序用 uni.request + onChunkReceived 兼容运行环境差异,两边最终都统一成"追加 answer、marked 渲染、滚动到底部"的页面更新逻辑。

相关推荐
风月说与山鬼3 小时前
七、uni-app页面与组件生命周期
前端·uni-app
PedroQue994 小时前
Vue-Router 2.4.0 新增可控重定向功能
前端·uni-app
梦曦i20 小时前
RouterLink H5端控制台错误修复
前端·uni-app
00后程序员张1 天前
Windows / Linux / Mac 上不用 Xcode 把 IPA 上传到 App Store,upload 命令详解
android·ios·小程序·https·uni-app·iphone·webview
风月说与山鬼1 天前
八、uni-app页面调用接口
uni-app
梦梦代码精2 天前
基于UniApp+Vue3+ThinkPHP 8,这套知识付费系统的架构设计有点东西
java·低代码·docker·uni-app·开源·php
风月说与山鬼2 天前
三、uni-app页面配置(pages.json)
前端·uni-app
风月说与山鬼2 天前
二、uni-app入口文件(main.js)
javascript·uni-app
xiaopang3 天前
一次小程序 Canvas 海报真机调试复盘:从空白导出到 iOS 保存失败
uni-app