
1. 流式响应在小程序里到底是个什么东西先把话说在前头微信小程序原生是不给你EventSource的也没有浏览器那套fetch的ReadableStream直接读。所以当产品经理跑过来跟你说“我们要做个像 ChatGPT 那样一个字一个字往外蹦的效果”很多人的第一反应是——这玩意儿在小程序里能做吗答案是能做而且做法不止一种但每一种都有它的脾气选错了后面全是坑。我先把概念掰开。所谓流式响应stream本质上是服务端不把整个结果一次性算完再返回而是算一点、吐一点客户端收到一点就渲染一点。传统请求像你去餐厅点菜厨师全做完才端上来流式像铁板烧师傅边做你边吃。放到小程序场景里最典型的应用就是大模型对话——你问一句回答是逐字生成的如果等全部生成完再显示用户盯着转圈十几秒体验直接崩掉。除了 AI 对话流式还常见于长文本生成、实时日志推送、进度反馈、大文件分片下载这些场景。那为什么小程序做流式比 Web 麻烦核心卡在三个地方。第一小程序的网络请求wx.request默认是“收完再回调”它不暴露底层的分块数据流你拿到的永远是完整响应体。第二小程序没有 DOM没有XMLHttpRequest的onprogress那种细粒度事件渲染要靠setData而setData是有性能和频率限制的。第三小程序的运行环境是双线程架构逻辑层和渲染层分离数据要跨线程传递高频更新会放大开销。所以这篇东西适合谁看如果你正在小程序里接大模型、做逐字输出、搞实时进度条或者你只是好奇“小程序到底能不能玩流”那往下看就对了。我会把几种主流方案的原理、代码、参数、坑点全摊开讲尽量让你看完能直接抄作业。需要说明的是下面涉及的具体接口行为和参数一部分来自公开文档的常见实践一部分是我自己在项目里反复试出来的经验值不同基础库版本可能有细微差异落地前建议先小范围验证。2. 方案选型三条路各有各的活法2.1 先搞清楚小程序网络层的底层约束在选方案之前得先明白小程序网络请求的边界在哪。wx.request支持responseType: arraybuffer和text但它不支持流式回调——也就是说服务端就算用 chunked 传输一点一点发wx.request也会在内部把数据攒齐等complete了才把完整结果给你。这一点是很多人一开始没意识到的以为设个什么参数就能拿到流结果折腾半天发现根本拿不到中间态。那有没有例外有。小程序从某个基础库版本开始对wx.request的enableChunked参数做了支持这个参数在部分版本里叫法或行为有调整具体以你项目的基础库为准。开启之后配合onChunkReceived回调就能拿到服务端分块返回的ArrayBuffer数据。这是目前最正统、最轻量的小程序流式方案不需要额外引入任何库。但它有前提服务端必须用Transfer-Encoding: chunked正常分块且返回的数据要能被你正确解码。另一条路是WebSocket。wx.connectSocket是小程序原生支持的双向通信服务端可以随时 push 消息过来。这条路不依赖 HTTP 分块机制天然适合流式缺点是服务端要额外维护 WS 连接且和普通 HTTP 接口是两套体系鉴权、网关、负载均衡都得单独考虑。第三条路是轮询polling严格说不算真流式但对某些场景够用客户端每隔几百毫秒问一次“好了没给我下一段”服务端维护一个任务 ID 和已生成内容的游标。它实现最简单、兼容性最好缺点是延迟高、请求多、服务端压力大只适合对实时性要求不极端的场景。2.2 三种方案横向对比别拍脑袋选我把三条路的关键维度拉成一张表你对着自己的场景挑维度enableChunked 分块WebSocket轮询实时性高接近逐块到达高服务端主动推低取决于轮询间隔服务端改造成本低普通 HTTP 加 chunked 即可高需维护长连接与心跳中需维护任务状态小程序端复杂度低原生 API中需处理重连、心跳低连接稳定性受单次请求超时约束需自己做断线重连天然无长连接问题适用场景AI 对话、文本流实时推送、多人协作后台任务进度查询主要坑点解码、分包边界、超时连接数限制、心跳、重连延迟、请求风暴我的实际选择逻辑是这样的如果是接大模型对话这类“一问一答、单次会话”的场景优先用 enableChunked因为它最贴近 HTTP 语义服务端几乎不用为小程序单独改造网关、鉴权、日志全都复用现有链路。如果是需要服务端主动推送、或者一个连接上要跑多轮交互的场景才上 WebSocket。轮询我只在“后台跑一个几分钟的长任务、用户只是偶尔看进度”这种场景用比如批量导出、报表生成。这里有个容易被忽略的点enableChunked拿到的数据是ArrayBuffer而大模型返回的通常是 UTF-8 编码的文本流。如果你直接对每个 chunk 单独decode很可能把一个多字节的中文字符从中间劈开出现乱码。这是分块方案里最经典的坑后面第 4 节我会专门讲怎么处理分包边界。2.3 为什么我不推荐一上来就上 WebSocket很多团队一听“流式”就条件反射上 WebSocket觉得这才是“实时”的正解。但在小程序里WebSocket 有几个现实约束你得先掂量。第一小程序对并发 WebSocket 连接数是有限制的虽然一般场景够用但如果你页面里多个组件各自建连很容易撞上限。第二WS 连接需要自己做心跳保活和断线重连小程序切后台、网络切换、弱网环境下连接断得比你想象中频繁重连逻辑写不好就是一堆“消息丢了”的线上问题。第三服务端要维护大量长连接对网关和资源是实打实的压力。反观enableChunked它本质还是一次 HTTP 请求请求结束连接就释放没有长连接维护成本超时、重试都能复用 HTTP 那套成熟机制。所以除非你的业务真的需要服务端主动 push否则我建议先用分块方案把需求跑通别为了“技术先进”给自己挖坑。3. 核心实现把分块流真正跑起来3.1 enableChunked 的完整调用骨架先上最关键的代码骨架。核心就三件事请求时开enableChunked监听onChunkReceived在回调里解码并增量渲染。// 发起流式请求 const requestTask wx.request({ url: https://your-api.example.com/chat/stream, method: POST, enableChunked: true, // 关键开启分块接收 responseType: arraybuffer, // 关键按二进制接收 header: { content-type: application/json, accept: text/event-stream }, data: { prompt: 帮我写一段产品介绍, sessionId: abc123 }, success(res) { // 注意开了 chunked 后success 里的 res.data 通常不是完整内容 // 真正的数据在 onChunkReceived 里 console.log(请求结束, res.statusCode) }, fail(err) { console.error(请求失败, err) } }) // 监听分块数据到达 requestTask.onChunkReceived((res) { // res.data 是 ArrayBuffer const chunk res.data // 交给解码器处理见 3.2 handleChunk(chunk) })这段代码看着简单但每一行都有讲究。enableChunked: true是开关不开的话onChunkReceived根本不会触发。responseType: arraybuffer保证你拿到的是二进制而不是被提前转成字符串——如果设成text小程序内部可能会做一次不完整的解码反而更容易乱码。accept: text/event-stream是告诉服务端“我期望 SSE 格式”服务端配合返回Content-Type: text/event-stream和 chunked 传输链路就通了。注意onChunkReceived的触发时机和 chunk 大小由服务端和网络共同决定你不能假设每个 chunk 是一条完整消息。一个 chunk 可能是半句话也可能是三句话粘在一起甚至可能把一个汉字劈成两半。所有解析逻辑都必须建立在“字节流”而非“消息流”的假设上。3.2 解码与分包边界中文乱码的根治办法前面说的乱码坑根子在于 UTF-8 是变长编码一个汉字占 3 个字节如果 chunk 边界正好切在第 1 和第 2 个字节之间你单独解码这个 chunk 就会得到乱码。正确做法是维护一个字节缓冲区把不完整的字节留到下一个 chunk 拼起来再解码。// 用 TextDecoder 做流式解码注意要开 stream 模式 let decoder null let textBuffer function initDecoder() { // 小程序环境部分版本支持 TextDecoder若不支持需自己实现 decoder new TextDecoder(utf-8) } function handleChunk(arrayBuffer) { // stream: true 表示这是流的一部分解码器会保留不完整字节 const text decoder.decode(new Uint8Array(arrayBuffer), { stream: true }) textBuffer text parseSSE(textBuffer) }这里的关键是decoder.decode(uint8, { stream: true })的第二个参数。开了stream: true解码器内部会记住“上次末尾有几个字节没凑成一个完整字符”下次解码时自动拼上。这比自己手写字节拼接靠谱得多。如果你的基础库版本不支持TextDecoder的 stream 选项那就得自己维护一个Uint8Array缓冲区判断末尾是否是完整 UTF-8 序列——这个逻辑比较绕建议优先确认环境支持情况。解码出文本后还要处理SSEServer-Sent Events格式的解析。标准 SSE 每条消息以data:开头以\n\n结尾。但流式到达时\n\n可能被切在两个 chunk 里所以解析时不能简单split(\n\n)就完事得保留最后一个不完整的片段let sseBuffer function parseSSE(text) { sseBuffer text const parts sseBuffer.split(\n\n) // 最后一段可能不完整留到下次 sseBuffer parts.pop() for (const part of parts) { const line part.trim() if (line.startsWith(data:)) { const payload line.slice(5).trim() if (payload [DONE]) { finishStream() return } try { const json JSON.parse(payload) appendContent(json.delta || json.content || ) } catch (e) { // 解析失败多半是分包导致忽略或记录 } } } }这套“缓冲区 保留残片”的模式是流式解析的通用套路不管你是解析 SSE 还是自定义分隔符思路都一样永远假设数据会被任意切断永远保留不完整部分。3.3 setData 的节流别让渲染拖垮逻辑层数据拿到了接下来是渲染。小程序里更新视图靠setData但setData是跨线程通信每次调用都有序列化和传输开销。如果你每收到一个 chunk 就setData一次大模型一秒吐几十个 token你的setData调用频率会高到让页面卡顿甚至丢帧。我的做法是节流合并用一个本地变量累积最新内容定时比如每 50 到 100 毫秒统一setData一次。这样既保证了视觉上的“逐字感”又把setData频率压到可控范围。let pendingContent let flushTimer null function appendContent(delta) { pendingContent delta if (!flushTimer) { flushTimer setTimeout(() { this.setData({ answer: this.data.answer pendingContent }) pendingContent flushTimer null }, 80) } }80 毫秒这个值是我反复调出来的经验值低于 50 毫秒setData太频繁低端机明显卡高于 150 毫秒肉眼能感觉到“一顿一顿”的逐字效果不自然。当然这跟设备性能、内容长度都有关你可以从 80 起步根据实测微调。另外如果内容特别长比如几千字每次setData都传全量字符串也会越来越慢这时候可以考虑只传增量、在视图层用数组拼接或者用wx.createSelectorQuery配合局部更新——不过后者复杂度高一般对话场景用全量 节流就够了。实操心得setData的数据量也有讲究。单次setData传输超过一定大小经验上几百 KB会明显变慢所以如果你的流式内容是超长文本最好做分页或虚拟列表别一股脑全塞进一个字段。4. 服务端配合与协议设计流不是客户端一个人的事4.1 服务端要满足哪些硬性条件客户端代码写得再漂亮服务端不配合也是白搭。要让enableChunked真正工作服务端必须满足几个条件。第一响应头要带Transfer-Encoding: chunked并且不能提前设置Content-Length——一旦设了长度服务端就没法“边算边发”了。第二要正确设置Content-Type: text/event-stream如果你用 SSE 格式或application/octet-stream纯二进制流并加上Cache-Control: no-cache防止中间层缓存。第三也是最容易翻车的——中间的反向代理和网关不能缓冲响应。我踩过最深的坑就是这个本地直连服务端流式完美一上测试环境就变成“等半天然后一次性全出来”。排查半天发现是网关默认开了响应缓冲把 chunk 攒起来才转发。解决办法是在网关配置里对这条路由关闭缓冲不同网关配置项名字不一样常见的有proxy_buffering off这类开关或者干脆让流式接口走一条不经过缓冲的独立链路。这个问题不解决你客户端调再多参数都没用。4.2 自定义协议 vs 标准 SSE怎么选服务端吐数据的格式我一般两种选标准 SSE或者自定义的“长度前缀 JSON”协议。SSE 的好处是生态成熟、调试方便浏览器直接能看格式就是data: {...}\n\n客户端解析逻辑前面已经写了。缺点是它基于文本如果传输二进制或者需要严格的消息边界就不太合适。自定义协议更适合对边界要求严格的场景。比如每条消息前面加 4 字节的长度头客户端先读长度、再读对应字节数天然解决分包问题。代价是客户端解析要自己写且不能用TextDecoder的 stream 模式偷懒。我的建议是纯文本对话用 SSE二进制或结构化数据用长度前缀。别为了“统一”硬套一种协议两种都实现一遍的成本其实不高。还有一个细节流式响应里最好设计一个明确的结束标记。SSE 里常用data: [DONE]自定义协议里可以是一个特殊 type。客户端收到结束标记后主动abort请求任务、清理定时器、关闭 loading 状态。没有结束标记的话你只能靠success回调或者超时来判断结束前者在 chunked 场景下行为不一定可靠后者会让用户白等。4.3 超时、重试与中断流式请求的健壮性设计流式请求持续时间可能很长大模型生成一段长文几十秒很正常而wx.request有默认超时。虽然可以在timeout参数里调大但无限调大不是办法——网络真断了你也不知道。我的做法是双层超时请求级超时设一个较大的值比如 60 秒兜底同时在客户端维护一个“最后收到数据的时间”如果超过比如 15 秒没有任何 chunk 到达就主动abort并提示用户重试。中断也很重要。用户在生成过程中点了“停止”或者页面卸载了你得主动调requestTask.abort()否则请求还在后台跑浪费流量和服务端资源。abort之后fail回调会触发注意在里面区分“用户主动中断”和“真实错误”别把主动停止也弹个错误提示。// 页面卸载时清理 onUnload() { if (this.requestTask) { this.requestTask.abort() } if (this.flushTimer) { clearTimeout(this.flushTimer) } }重试策略上流式请求和普通请求不一样——你不能简单“失败了从头再来”因为已经吐出来的内容用户已经看到了重来会导致内容重复或跳变。比较稳妥的做法是记录已接收的内容和对应的服务端游标如果有重试时带上游标让服务端续传。如果服务端不支持续传那就只能整段重来但要在 UI 上明确告诉用户“重新生成”而不是悄悄覆盖。5. 常见问题排查与避坑实录5.1 那些让人抓狂的典型故障流式这块的坑我整理成一张速查表基本都是线上或联调时真实遇到过的现象可能原因排查方向解决思路onChunkReceived 完全不触发没开 enableChunked / 基础库版本低打印基础库版本确认参数升级基础库或换方案数据一次性全到没有流式感网关/代理缓冲了响应抓包看响应是否分块关闭网关缓冲中文乱码、出现问号方块chunk 边界劈开多字节字符检查解码是否用 stream 模式用 TextDecoder stream 或自建缓冲内容重复或跳变重试时从头开始检查重试逻辑带游标续传或明确提示重来页面卡顿、掉帧setData 频率过高统计 setData 调用次数节流合并控制单次数据量请求莫名中断超时设置过短看 fail 回调的 errMsg调大超时 空闲检测切后台后流断了小程序后台限制复现切后台场景提示用户保持前台或做断点续传这张表里网关缓冲和中文乱码是我见过最高频的两个。前者隐蔽性强因为本地开发环境往往没有那层网关一上测试环境才暴露后者则是因为很多人图省事直接String.fromCharCode或者对每个 chunk 单独decode没考虑变长编码。5.2 弱网和切后台流式最脆弱的时刻小程序切到后台一段时间后网络请求可能被系统挂起甚至断开这是平台行为你没法完全对抗。表现就是用户切回来发现内容停在半截也不报错就那么卡着。我的处理方式是监听onShow如果发现流式请求处于“进行中但长时间无数据”的状态就主动判定为中断提示用户“连接已断开点击继续”然后走续传逻辑。弱网下还有一个现象chunk 到达间隔变得很不均匀可能几秒来一个也可能突然来一大坨。这时候节流参数如果写死体验会很怪。我的经验是让节流间隔动态一点——如果短时间内 chunk 密集就适当拉长合并窗口如果 chunk 稀疏就立即 flush保证每个字都能及时显示。这个逻辑不复杂但能明显提升弱网下的观感。避坑提醒不要在小程序里对同一个 URL 同时发起多个流式请求做“并发加速”连接数限制和带宽争抢会让每个流都变慢得不偿失。需要并发的场景老老实实排队或者用 WebSocket 单连接多路复用。5.3 调试手段怎么看到“流”真的在流调试流式最直接的办法是打时间戳日志。在onChunkReceived里记录每次回调的时间和字节数如果所有回调时间戳几乎一样、字节数累加等于总长度那说明根本没流起来是缓冲问题如果时间戳均匀分布说明流是通的问题在别处。这个简单的日志能帮你快速定位是“服务端/网关没流”还是“客户端没处理好”。另外服务端侧可以用curl加--no-buffer参数直接观察分块输出排除客户端因素。如果curl能看到逐块输出而小程序看不到那问题基本锁定在小程序端或中间链路如果curl也是一次性输出那就是服务端或网关的锅。分层排查别一上来就怀疑客户端代码。6. 我个人的几点实战体会流式这个东西技术本身不算特别难难的是链路上每一环都得配合——服务端要真流、网关要不缓冲、客户端要会解码会节流、异常要能兜底。任何一环掉链子用户看到的就是“转圈半天然后唰一下全出来”那还不如不做流式。我在实际项目里最大的感受是先把最小闭环跑通再谈优化。一开始别急着上 WebSocket、别急着做续传、别急着搞动态节流就用enableChunked 简单 SSE 解析 固定节流把“能逐字显示”这个核心体验做出来。跑通之后你自然会遇到乱码、卡顿、断流这些具体问题那时候再针对性解决比一开始就设计一套复杂架构要高效得多。还有个小技巧分享给做 AI 对话的朋友流式渲染时光标那个闪烁的竖线最好用 CSS 动画实现别用setData去控制它的显隐否则又是一次无谓的跨线程通信。内容更新归内容更新光标动画归 CSS各司其职性能会好很多。这个细节不起眼但在低端机上能明显感觉到差别。