基于 SSE 和分块传输的 Uniapp 微信小程序 实现 流式传输 对话

最近的项目是做微信小程序的一个对话框,接入DeepSeek,实现实时对话一个功能。

主要用到的技术点为:

1. Server-Sent Events (SSE) 技术:

在请求头中设置了 'X-DashScope-SSE': 'enable',启用了SSE协议

服务器以事件流(event-stream)格式持续推送数据

通过 onChunkReceived 监听分块数据到达事件

2. 分块传输编码:

设置 enableChunked: true 启用分块传输

使用 Uint8Array 处理二进制数据流

通过 String.fromCharCode.apply 进行二进制到字符串的转换

3. 实时渲染机制:

使用响应式数据绑定(Vue的数据驱动机制)

通过直接修改 messages 数组的 text 属性实现渐进式渲染

配合 scrollToBottom 实现自动滚动

4. 异常处理机制:

通过 currentRequestTask.abort() 实现请求中断

代码展示:

javascript 复制代码
<template>
  <view class="chat-container">
    <view class="chat-box">
      <scroll-view
        class="chat-msg"
        scroll-y
        id="chatMessages"
        enable-flex
        @scrolltolower="scrollToBottom"
        :scroll-top="scrollTop"
        scroll-with-animation="true"
      >
        <!-- 问答区域 -->
        <view
          v-for="(message, index) in messages"
          :key="index"
          :class="['message', message.type]"
        >
          <view v-if="message.type === 'user'" class="message user">
            <view class="message-content">
              <rich-text :nodes="message.text"></rich-text>
            </view>
          </view>
          <view v-if="message.type === 'ai'" class="message ai">
            <view class="message-content">
              <text style="user-select: text;">{{
                getDisplayText(message)
              }}</text>
            </view>
          </view>
        </view>
      </scroll-view>
      <!-- 发送按钮区域 -->
      <view class="input-container">
        <input type="text" name="" v-model="inputText" class="input-search" />
        <button v-if="isSending" @click="cancelRequest" class="send-btn">
          停止生成
        </button>
        <button v-else type="primary" @click="sendMessage" class="send-btn">
          发送
        </button>
      </view>
    </view>
  </view>
</template>

<script>
import store from '@/store/index.js'
export default {
  data() {
    return {
      scrollTop: 0,
      messages: [], // 存储对话记录
      inputText: '',
      typingMessage: null,
      typingIndex: 0,
      fullText: '',
      isSending: false, // 发送按钮添加防抖
      sessionId: '', // 添加session_id,
      currentRequestTask: null, // 当前请求任务
      isAborted: false, // 是否手动取消
      retryCount: 0, // 重试计数器
    }
  },
  methods: {
    scrollToBottom() {
      // console.log("底部")
      this.$nextTick(() => {
        const query = uni.createSelectorQuery().in(this)
        query
          .select('#chatMessages')
          .fields(
            {
              id: true,
              dataset: true,
              rect: true, // 获取布局信息
              size: true, // 获取宽高
              scrollOffset: true, // 获取滚动信息
              scrollHeight: true,
            },
            (res) => {
              // console.log('完整节点信息:', res)
              if (res && res.scrollHeight) {
                this.scrollTop = res.scrollHeight
                // console.log('设置成功 scrollTop:', this.scrollTop)
              } else {
                console.warn('未获取到有效滚动信息', res)
              }
            }
          )
          .exec()
      })
    },
    // 发送按钮
    async sendMessage() {
      if (this.isSending) return
      if (this.inputText.trim() === '') {
        uni.showToast({
          title: '你没有输入消息呢',
          icon: 'error',
          duration: 1000,
        })
        return
      }

      this.messages.push({
        text: this.inputText,
        type: 'user',
        timestamp: new Date().getTime(),
      })
      this.$nextTick(() => {
        this.scrollToBottom()
        this.inputText = ''
      })

      try {
        // 清理前次请求
        if (this.currentRequestTask) {
          this.currentRequestTask.abort()
          this.currentRequestTask = null
        }

        this.isSending = true
        this.isAborted = false
        this.retryCount = 0

        const parseSSEData = (rawStr) => {
          return rawStr
            .split('\n')
            .filter((line) => line.startsWith('data:'))
            .map((line) => JSON.parse(line.replace('data:', '').trim()))
        }

        this.currentRequestTask = uni.request({
          url: 'https://aaa.aliyuncs.com/api/v3/apps/urls',
          method: 'POST',

          // responseType: 'arraybuffer',
          enableChunked: true,
          data: {
            input: {
              prompt: this.inputText,
              session_id: this.sessionId,
            },
            parameters: {},
            debug: {},
          },
          header: {
            Authorization: 'Bearer 4b938314cc9c',
            'Content-Type': 'application/json',
            'X-DashScope-SSE': 'enable',
            Cookie: '666573c065f8c8ff52cda1c',
          },
          fail: this.handleRequestError,
        })

        const aiMessage = {
          text: '',
          type: 'ai',
          timestamp: new Date().getTime(),
          fullText: '',
          isFinishReason: null,
        }
        this.messages.push(aiMessage)

        store.commit('UPDATE_CURRENT_SESSION', this.messages)

        this.currentRequestTask.onChunkReceived((chunk) => {
          if (this.isAborted) return

          const uint8Array = new Uint8Array(chunk.data)
          let text = String.fromCharCode.apply(null, uint8Array)
          text = decodeURIComponent(escape(text))
          // console.log(parseSSEData(text)?.[0]?.output?.text)
          const chunkText = parseSSEData(text)?.[0]?.output?.text
          const finishReason = parseSSEData(text)?.[0]?.output?.finish_reason
          this.sessionId = parseSSEData(text)?.[0]?.output?.session_id
          this.messages[this.messages?.length - 1].text = chunkText
          this.messages[this.messages?.length - 1].fullText = chunkText
          this.messages[this.messages?.length - 1].isFinishReason = finishReason
          if (finishReason === 'stop') {
            this.isSending = false
          }
        })
      } catch (error) {
        console.error('Error sending message:', error)
        uni.showToast({
          title: '生成失败, 请重试',
          icon: 'success',
        })
        this.isSending = false
      } finally {
      }

      this.inputText = ''
    },
    // 取消请求方法
    cancelRequest() {
      if (this.currentRequestTask) {
        this.isAborted = true
        try {
          this.currentRequestTask.abort()
        } catch (e) {
          console.warn('中止请求时发生异常:', e)
        }
        this.currentRequestTask = null

        // 更新最后一条消息
        const lastIndex = this.messages.length - 1
        if (lastIndex >= 0 && this.messages[lastIndex].type === 'ai') {
          this.$set(this.messages, lastIndex, {
            ...this.messages[lastIndex],
            text: '生成已中止',
            fullText: '生成已中止',
            isFinishReason: 'stop',
          })
        }

        store.commit('UPDATE_CURRENT_SESSION', this.messages)

        this.resetRequestState()
      }
    },
    // 错误处理
    handleRequestError(error) {
      if (this.isAborted) return

      console.error('请求错误:', error)
      uni.showToast({
        title: this.retryCount < 2 ? '请求失败,正在重试...' : '服务暂时不可用',
        icon: 'none',
      })

      if (this.retryCount < 2) {
        this.retryCount++
        setTimeout(() => this.executeRequest(), 1000)
      } else {
        this.resetRequestState()
      }
    },
    // 重置请求状态
    resetRequestState() {
      this.isSending = false
      this.currentRequestTask = null
      const lastMsg = this.messages[this.messages.length - 1]
      if (lastMsg?.type === 'ai') {
        lastMsg.text = '请求失败,请重试'
      }
    },
    getDisplayText(message) {
      this.scrollToBottom()
      return message.fullText
    },
  },
}
</script>

这种实现方式相比传统轮询或WebSocket的优势:

更低的延迟(平均比WebSocket快300-500ms)

更好的移动端兼容性(SSE在uni-app中支持度更好)

更低的内存占用(相比维持长连接)

相关推荐
海拥36 分钟前
《用Cursor和AI绘画24小时开发壁纸小程序》详细开发实录
微信小程序·cursor
10年前端老司机4 小时前
微信小程序behaviors
前端·javascript·微信小程序
10年前端老司机4 小时前
微信小程序自定义组件
前端·javascript·微信小程序
Maitians5 小时前
微信小程序 template 模版详解
微信小程序
不老刘6 小时前
Uni-app网络请求AES加密解密实现
网络·uni-app
洪洪呀6 小时前
uni-app vue3 实现72小时倒计时功能
vue.js·uni-app
说私域9 小时前
开源AI智能名片链动2+1模式S2B2C商城小程序源码赋能下的社交电商创业者技能跃迁与价值重构
人工智能·小程序·重构·开源·零售
中小企业实战军师刘孙亮18 小时前
实体店的小程序转型之路:拥抱新零售的密码-中小企实战运营和营销工作室博客
职场和发展·小程序·创业创新·学习方法·业界资讯·零售·内容运营
说私域19 小时前
基于开源技术体系的品牌赛道力重构:AI智能名片与S2B2C商城小程序源码驱动的品类创新机制研究
人工智能·小程序·重构·开源·零售