微信小程序流式请求实战:用uni.request实现ChatGPT逐字输出效果
最近在开发一个集成AI对话功能的微信小程序时,遇到了一个棘手的问题:如何实现类似ChatGPT那样的逐字输出效果?市面上大多数解决方案要么使用WebSocket(增加服务器负担),要么采用H5嵌套(影响用户体验)。经过反复尝试,我发现uni-app的uni.request配合enableChunked参数可以完美解决这个问题。下面就把我的实战经验分享给大家。
1. 流式请求的核心原理与方案对比
1.1 为什么小程序需要特殊处理流式请求
微信小程序的网络请求API与传统Web环境有所不同,主要表现在:
- 不支持标准的Stream API:浏览器中的
fetch和XMLHttpRequest可以处理流式响应,但小程序环境受限 - 数据传输限制:小程序对单次响应数据大小有限制,不适合大段文本一次性返回
- 性能考量:完整接收大响应再处理会导致界面卡顿,影响用户体验
1.2 常见解决方案对比
| 方案 | 实现难度 | 服务器压力 | 用户体验 | 适用场景 |
|---|---|---|---|---|
| WebSocket | 高 | 高(长连接) | 好 | 实时性要求极高的场景 |
| 轮询 | 低 | 中(频繁请求) | 差 | 简单场景,更新频率低 |
| H5嵌套 | 中 | 低 | 一般 | 已有H5实现的快速集成 |
| Chunked Transfer | 中 | 低 | 好 | 本文推荐方案 |
提示:Chunked Transfer Encoding是HTTP/1.1标准的一部分,几乎所有服务器和客户端都支持,兼容性最好。
2. 后端配置:ThinkPHP实现分块传输
2.1 关键响应头设置
要让服务器支持分块传输,需要正确设置响应头。以下是在ThinkPHP中的配置示例:
// 设置允许跨域 header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); // 关键分块传输头 header('Transfer-Encoding: chunked'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); // 特别针对Nginx服务器 header('Connection: keep-alive');2.2 数据分块发送逻辑
在业务逻辑中,我们需要将响应数据分块发送。以下是处理AI响应并分块返回的核心代码:
public function chatResponse() { // 标识小程序请求 $isWxapp = input('is_wxapp', false); // 获取AI生成的响应内容(模拟) $aiResponse = "这是AI生成的逐字输出内容..."; if ($isWxapp) { // 分块发送逻辑 $chunkSize = 5; // 每5个字符作为一个块 $length = strlen($aiResponse); for ($i = 0; $i < $length; $i += $chunkSize) { $chunk = substr($aiResponse, $i, $chunkSize); echo sprintf("%x\r\n", strlen($chunk)); // 块长度(16进制) echo $chunk . "\r\n"; // 块内容 ob_flush(); flush(); usleep(100000); // 模拟网络延迟,实际可去掉 } // 结束标记 echo "0\r\n\r\n"; ob_flush(); flush(); } else { // 普通HTTP响应处理 return json(['content' => $aiResponse]); } }3. 前端实现:uni.request的enableChunked详解
3.1 基础请求配置
在uni-app中,我们需要特别关注uni.request的几个关键参数:
const requestTask = uni.request({ url: 'https://your-api.com/chat', method: 'POST', responseType: 'arraybuffer', // 必须设为arraybuffer enableChunked: true, // 开启分块传输 timeout: 30000, // 适当延长超时时间 data: { message: userInput, is_wxapp: true // 告诉后端这是小程序请求 }, // 其他配置... });3.2 处理分块数据的完整流程
接收和处理分块数据是整个实现中最关键的部分。以下是完整的处理流程:
- 监听onChunkReceived事件:接收原始二进制数据块
- ArrayBuffer转Base64:使用uni-app提供的API转换
- Base64解码:获取可读文本内容
- 拼接和处理内容:更新UI实现逐字显示效果
requestTask.onChunkReceived((response) => { // 1. 获取ArrayBuffer数据 const arrayBuffer = response.data; // 2. 转换为Base64 const base64Str = uni.arrayBufferToBase64(arrayBuffer); // 3. Base64解码 const decodedStr = atob(base64Str); // 或使用Buffer对象 // 4. 处理特殊结束标记 if (decodedStr.trim() === '0') { console.log('Stream ended'); this.loading = false; return; } // 5. 更新UI显示 this.chatContent += decodedStr; // 6. 自动滚动到底部 this.scrollToBottom(); });3.3 编码转换的注意事项
在实际测试中,我发现直接使用arrayBufferToBase64然后解码有时会出现乱码问题。经过多次尝试,找到了更稳定的处理方式:
// 更健壮的编码转换方案 function bufferToString(buffer) { const bytes = new Uint8Array(buffer); let str = ''; // 分块处理避免性能问题 const chunkSize = 1024; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, i + chunkSize); str += String.fromCharCode.apply(null, chunk); } // 处理可能的BOM头 if (str.charCodeAt(0) === 0xFEFF) { str = str.substr(1); } return decodeURIComponent(escape(str)); }4. 实战优化与边界情况处理
4.1 性能优化技巧
- 合理设置分块大小:太小会增加请求次数,太大会影响实时性
- 防抖处理UI更新:避免频繁setData导致性能问题
- 内存管理:及时清理已处理的数据块
// 优化后的UI更新策略 let updateTimer = null; let pendingUpdate = ''; requestTask.onChunkReceived((response) => { const content = processChunk(response.data); pendingUpdate += content; // 防抖处理,每200ms更新一次UI clearTimeout(updateTimer); updateTimer = setTimeout(() => { this.chatContent += pendingUpdate; pendingUpdate = ''; this.scrollToBottom(); }, 200); });4.2 常见问题解决方案
乱码问题:
- 确保前后端编码一致(推荐UTF-8)
- 检查是否有BOM头干扰
- 尝试不同的解码方式
连接中断处理:
requestTask.onError((err) => { console.error('请求出错:', err); this.loading = false; uni.showToast({ title: '连接中断,请重试', icon: 'none' }); // 可以尝试自动重连 this.retryCount = this.retryCount || 0; if (this.retryCount < 3) { setTimeout(() => { this.startChat(); this.retryCount++; }, 1000); } });超时设置:
- 根据网络状况调整timeout
- 考虑实现心跳机制保持连接
4.3 完整示例代码
下面是一个可直接集成到项目中的完整组件实现:
// components/StreamChat.vue export default { data() { return { messages: [], currentMessage: '', loading: false, requestTask: null }; }, methods: { sendMessage() { if (this.loading || !this.currentMessage.trim()) return; this.loading = true; this.messages.push({ role: 'user', content: this.currentMessage }); this.messages.push({ role: 'assistant', content: '' }); const messageIndex = this.messages.length - 1; this.currentMessage = ''; this.requestTask = uni.request({ url: 'https://your-api.com/chat', method: 'POST', responseType: 'arraybuffer', enableChunked: true, timeout: 30000, data: { message: this.messages[messageIndex - 1].content, is_wxapp: true }, success: () => { this.loading = false; }, fail: (err) => { console.error(err); this.loading = false; uni.showToast({ title: '请求失败', icon: 'none' }); } }); let buffer = ''; this.requestTask.onChunkReceived((response) => { try { const uint8Array = new Uint8Array(response.data); const chunk = this.uint8ToString(uint8Array); if (chunk.trim() === '0') { this.loading = false; return; } buffer += chunk; const lines = buffer.split('\n'); buffer = lines.pop(); // 剩余不完整行 lines.forEach(line => { if (line.startsWith('data: ')) { const content = line.substring(6).trim(); if (content) { this.messages[messageIndex].content += content; this.$forceUpdate(); this.scrollToBottom(); } } }); } catch (e) { console.error('处理分块出错:', e); } }); }, uint8ToString(uint8Array) { let str = ''; for (let i = 0; i < uint8Array.length; i++) { str += String.fromCharCode(uint8Array[i]); } return decodeURIComponent(escape(str)); }, scrollToBottom() { this.$nextTick(() => { const query = uni.createSelectorQuery().in(this); query.select('.chat-container').boundingClientRect(data => { uni.pageScrollTo({ scrollTop: data.height, duration: 300 }); }).exec(); }); }, cancelRequest() { if (this.requestTask) { this.requestTask.abort(); this.loading = false; } } }, beforeDestroy() { this.cancelRequest(); } };5. 进阶应用与扩展思考
5.1 支持Markdown渲染
如果AI返回的内容包含Markdown格式,可以进一步优化显示效果:
// 在接收处理逻辑中添加 import marked from 'marked'; // ...在内容更新时 this.messages[messageIndex].content = marked(plainText);5.2 实现打字机动画效果
为了更好的用户体验,可以添加CSS动画模拟打字效果:
.typewriter { overflow: hidden; border-right: 2px solid #333; white-space: pre-wrap; animation: blink-caret 0.75s step-end infinite; } @keyframes blink-caret { from, to { border-color: transparent } 50% { border-color: #333; } }5.3 性能监控与优化建议
监控指标:
- 分块到达间隔时间
- 解码处理耗时
- 内存使用情况
优化建议:
- 对于长对话,考虑定期清理历史消息
- 实现分页加载机制
- 使用虚拟列表优化长内容渲染
在实际项目中,我发现这套方案不仅能用于ChatGPT类应用,还可以扩展到:
- 实时日志展示
- 长文章分块加载
- 大文件下载进度显示
- 实时数据监控仪表盘