微信小程序流式请求实战用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类应用还可以扩展到实时日志展示长文章分块加载大文件下载进度显示实时数据监控仪表盘