避开WebSocket和H5嵌套!用uni-app的enableChunked,5步实现小程序ChatGPT打字机效果

在小程序开发中,实现类似ChatGPT的实时打字机效果一直是个技术难点。传统方案往往依赖WebSocket或H5嵌套,但这些方法要么实现复杂,要么体验不佳。本文将介绍一种纯前端解决方案,利用uni-app的enableChunked特性,只需5个步骤就能实现流畅的实时回复效果。

1. 理解分块传输的基本原理

分块传输编码(Chunked Transfer Encoding)是HTTP协议中的一种数据传输方式,它允许服务器将响应分成多个"块"发送。对于小程序中的实时交互场景,这种技术有以下优势:

  • 实时性:数据可以分段到达,无需等待完整响应
  • 低延迟:每个数据块到达后可以立即处理
  • 资源友好:不需要维持长连接,节省服务器资源

在uni-app中,enableChunked: true配置会启用对这种分块传输的支持。当与responseType: 'text'配合使用时,可以监听onChunkReceived事件来实时获取数据片段。

提示:分块传输与WebSocket不同,它仍然基于HTTP协议,不需要额外的握手过程或连接维护。

2. 配置请求参数

正确的请求配置是实现分块传输的基础。以下是一个完整的请求配置示例:

const requestTask = uni.request({
  url: '你的API地址',
  method: 'POST',  // 根据后端要求选择GET或POST
  responseType: 'text',
  enableChunked: true,
  header: {
    'Content-Type': 'application/json',
    // 其他必要的请求头
  },
  data: {
    // 你的请求参数
  }
})

关键参数说明:

参数 类型 必填 说明
responseType string 必须设置为'text'
enableChunked boolean 启用分块传输,设为true
method string 根据后端接口要求设置

3. 处理分块数据

配置好请求后,需要通过onChunkReceived事件监听器来处理到达的数据块。以下是数据处理的核心代码:

requestTask.onChunkReceived(function(response) {
  // 将ArrayBuffer转换为Base64
  const arrayBuffer = response.data
  const uint8Array = new Uint8Array(arrayBuffer)
  let text = uni.arrayBufferToBase64(uint8Array)
  
  // 将Base64解码为UTF-8文本
  text = new Buffer(text, 'base64').toString('utf8')
  
  // 处理特殊结束标记
  if (text.trim() === '0') {
    console.log('数据传输完成')
    return
  }
  
  // 处理实际内容数据
  if (text.includes('content')) {
    try {
      const jsonStr = text.split('success: ')[1]
      const data = JSON.parse(jsonStr)
      // 更新UI显示
      this.updateChatContent(data.content)
    } catch (e) {
      console.error('数据解析错误:', e)
    }
  }
})

数据转换流程说明:

  1. 接收到的数据是ArrayBuffer格式
  2. 先转换为Uint8Array
  3. 使用uni-app的arrayBufferToBase64转为Base64
  4. 再将Base64解码为UTF-8文本
  5. 最后解析JSON内容

4. 实现流畅的"打字机"效果

要让文本像打字机一样逐个字符显示,需要一些前端技巧。以下是实现方案:

// 在页面data中定义
data() {
  return {
    chatContent: '',
    typingSpeed: 30, // 打字速度(毫秒/字符)
    typingTimer: null
  }
},

methods: {
  updateChatContent(newText) {
    // 清除之前的定时器
    if (this.typingTimer) clearTimeout(this.typingTimer)
    
    const targetContent = this.chatContent + newText
    let currentIndex = this.chatContent.length
    
    const typeNextChar = () => {
      if (currentIndex < targetContent.length) {
        this.chatContent = targetContent.substring(0, currentIndex + 1)
        currentIndex++
        this.typingTimer = setTimeout(typeNextChar, this.typingSpeed)
        
        // 自动滚动到底部
        this.scrollToBottom()
      }
    }
    
    typeNextChar()
  },
  
  scrollToBottom() {
    uni.createSelectorQuery()
      .select('#chat-container')
      .boundingClientRect(rect => {
        uni.pageScrollTo({
          scrollTop: rect.bottom,
          duration: 300
        })
      })
      .exec()
  }
}

优化技巧:

  • 速度控制:通过调整typingSpeed可以改变打字速度
  • 平滑滚动:在添加新内容时自动滚动到最新消息
  • 性能优化:使用substring而非逐个字符拼接

5. 错误处理与性能优化

在实际应用中,健壮的错误处理和性能优化必不可少。以下是关键点:

常见错误处理:

requestTask.onChunkReceived(function(response) {
  // ...数据转换代码...
  
  // 错误处理
  if (text.includes('error')) {
    const errorMsg = text.replace("event: error\ndata: ", "").replace("\r\n", "")
    try {
      const error = JSON.parse(errorMsg)
      this.showErrorToast(error.msg)
      return
    } catch (e) {
      console.error('错误信息解析失败:', e)
    }
  }
})

methods: {
  showErrorToast(message) {
    uni.showToast({
      title: message,
      icon: 'none',
      duration: 3000
    })
  }
}

性能优化建议:

  1. 节流处理:对于高频更新的内容,可以适当缓冲几个数据块再更新UI
  2. 内存管理:长时间对话时,注意清理不再需要的历史数据
  3. 网络中断处理:添加超时检测和重试机制
  4. 加载状态:显示清晰的加载指示器
// 示例:添加加载状态
data() {
  return {
    isLoading: false
  }
},

methods: {
  startRequest() {
    this.isLoading = true
    const requestTask = uni.request({
      // ...配置...
      complete: () => {
        this.isLoading = false
      }
    })
  }
}

完整示例代码

以下是一个完整的uni-app页面示例,整合了所有关键功能:

<template>
  <view class="container">
    <scroll-view id="chat-container" scroll-y class="chat-area">
      <view class="message" v-for="(msg, index) in messages" :key="index">
        {{ msg.content }}
      </view>
      <view v-if="isTyping" class="typing-indicator">...</view>
    </scroll-view>
    
    <view class="input-area">
      <input v-model="inputText" placeholder="输入消息..." />
      <button @click="sendMessage" :disabled="isLoading">发送</button>
    </view>
  </view>
</template>

<script>
export default {
  data() {
    return {
      inputText: '',
      messages: [],
      isTyping: false,
      isLoading: false,
      currentResponse: '',
      typingSpeed: 30
    }
  },
  methods: {
    sendMessage() {
      if (!this.inputText.trim()) return
      
      // 添加用户消息
      this.messages.push({
        role: 'user',
        content: this.inputText
      })
      
      // 添加空的AI回复占位
      this.messages.push({
        role: 'assistant',
        content: ''
      })
      
      const userMessage = this.inputText
      this.inputText = ''
      this.isLoading = true
      this.isTyping = true
      
      const requestTask = uni.request({
        url: '你的API地址',
        method: 'POST',
        responseType: 'text',
        enableChunked: true,
        header: {
          'Content-Type': 'application/json'
        },
        data: {
          message: userMessage
        },
        complete: () => {
          this.isLoading = false
          this.isTyping = false
        }
      })
      
      requestTask.onChunkReceived(response => {
        const arrayBuffer = response.data
        const uint8Array = new Uint8Array(arrayBuffer)
        let text = uni.arrayBufferToBase64(uint8Array)
        text = new Buffer(text, 'base64').toString('utf8')
        
        if (text.trim() === '0') {
          return
        }
        
        if (text.includes('error')) {
          const errorMsg = text.replace("event: error\ndata: ", "").replace("\r\n", "")
          try {
            const error = JSON.parse(errorMsg)
            this.showErrorToast(error.msg)
            this.messages.pop() // 移除空的AI回复
          } catch (e) {
            console.error('错误解析失败:', e)
          }
          return
        }
        
        if (text.includes('content')) {
          try {
            const jsonStr = text.split('success: ')[1]
            const data = JSON.parse(jsonStr)
            this.currentResponse += data.content
            this.typeResponse()
          } catch (e) {
            console.error('数据解析错误:', e)
          }
        }
      })
    },
    
    typeResponse() {
      const aiMessageIndex = this.messages.length - 1
      let currentLength = this.messages[aiMessageIndex].content.length
      
      if (currentLength < this.currentResponse.length) {
        this.messages[aiMessageIndex].content = 
          this.currentResponse.substring(0, currentLength + 1)
        
        this.$nextTick(() => {
          this.scrollToBottom()
        })
        
        setTimeout(this.typeResponse, this.typingSpeed)
      }
    },
    
    scrollToBottom() {
      uni.createSelectorQuery()
        .select('#chat-container')
        .boundingClientRect(rect => {
          uni.pageScrollTo({
            scrollTop: rect.bottom,
            duration: 100
          })
        })
        .exec()
    },
    
    showErrorToast(message) {
      uni.showToast({
        title: message,
        icon: 'none',
        duration: 3000
      })
    }
  }
}
</script>

<style>
.container {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.chat-area {
  flex: 1;
  padding: 20rpx;
}

.input-area {
  display: flex;
  padding: 20rpx;
  border-top: 1px solid #eee;
}

.input-area input {
  flex: 1;
  margin-right: 20rpx;
  padding: 10rpx 20rpx;
  border: 1px solid #ddd;
  border-radius: 8rpx;
}

.typing-indicator {
  padding: 10rpx;
  color: #999;
}
</style>

在实际项目中,根据后端API的具体响应格式,可能需要对数据处理部分进行相应调整。这个方案避免了WebSocket的复杂性,也不需要使用H5嵌套,完全基于uni-app的原生能力实现,具有更好的性能和兼容性。

更多推荐