微信小程序流式响应实战:enableChunked分块解码与踩坑指南
微信小程序里做流式响应这个需求这两年明显变多了。用户习惯了对话式产品一个字一个字往外蹦的反馈节奏再让他们盯着一个转圈加载等上十几秒才看到整段结果体验落差非常大。但小程序这个运行环境比较特殊它不像浏览器那样有标准的EventSource也不像 Node 环境那样随手就能拿到可读流wx.request的enableChunked配置、分块数据的解码、页面卸载时的连接清理每一步都有坑。我前后在几个对话类、文档生成类的小程序项目里踩过一轮这里把完整链路和踩坑经验摊开聊聊适合已经能写基础小程序、想上流式交互的开发者参考也适合产品同学理解这个能力边界在哪。1. 先搞清楚小程序里“流”到底卡在哪很多人第一反应是去搜“小程序 SSE”搜完发现官方文档里没有EventSource这个东西于是转头想用WebSocket硬扛。方向不算错但得先明白小程序环境到底缺了什么、又给了什么不然选型会走偏。1.1 浏览器那套流式方案为什么搬不过来浏览器里做流式接收主流就两条路EventSource或者fetch配合ReadableStream手动读。前者是 SSE 专用接口后者更灵活。小程序里这两样都没有EventSource压根不存在fetch也不是标准实现XMLHttpRequest的responseType不支持stream。换句话说你在 Web 端写好的那套reader.read()循环复制到小程序里直接报错。那小程序给了什么wx.request从基础库 2.20.2 开始支持enableChunked: true开启后服务端返回的数据会以分块形式通过onChunkReceived回调吐出来。这是目前最接近“流式”的原生能力。另一个选择是wx.connectSocket走 WebSocket服务端主动推。两者不是替代关系得看你的后端架构和部署条件。注意enableChunked有基础库版本门槛低版本用户会拿不到分块回调。上线前务必在app.json里设好最低基础库版本或者在代码里做能力探测降级。1.2 enableChunked 和 WebSocket 的取舍逻辑我一般这样判断如果后端本来就是 HTTP 接口、只是想把一次性返回改成边算边发优先用enableChunked改动最小服务端把Transfer-Encoding: chunked打开、按块 flush 就行。如果后端本身是长连接架构、或者需要双向通信比如中途要发取消指令、调整参数那 WebSocket 更合适。维度enableChunkedWebSocket服务端改造量小HTTP 接口加 flush中需要长连接服务双向通信不支持支持断线重连需自行处理需自行处理代理/网关兼容较好本质是 HTTP部分网关需额外配置基础库要求2.20.2很早就有还有个现实因素很多团队的网关对长连接支持不友好HTTP 分块反而更容易过。所以我的默认建议是先试enableChunked跑不通再考虑 WebSocket。1.3 分块回调拿到的不是“文本”是 ArrayBuffer这是新手最容易翻车的地方。onChunkReceived回调里的res.data是ArrayBuffer不是字符串。你直接console.log会看到一堆字节直接拼接到页面上会乱码。必须先用TextDecoder解码而且要考虑多字节字符被切块切断的情况——一个中文字符占 3 个字节如果服务端 flush 的边界正好切在字符中间单块解码就会出问题。const decoder new TextDecoder(utf-8) // 注意默认非 stream 模式跨块的多字节字符会丢正确做法是用stream: true的增量解码让解码器自己缓存不完整的字节序列下一块来了再拼。这个细节后面单独展开讲先记住结论分块数据必须增量解码不能每块独立 decode。2. 把 enableChunked 跑通的最小闭环理论说再多不如跑一遍。这一节给一个能直接抄的最小实现从请求发起到数据上屏中间的解码、拼接、异常处理都带上。2.1 请求配置里几个不能省的字段先看请求部分。除了enableChunked: true还有几个字段值得注意const task wx.request({ url: https://your-api/stream, method: POST, enableChunked: true, responseType: arraybuffer, // 关键避免框架尝试按文本解析 header: { content-type: application/json, accept: text/event-stream }, data: { prompt: ... }, success(res) { // 注意流式场景下 success 在流结束后才触发 }, fail(err) { // 网络层失败 } }) task.onChunkReceived((res) { // res.data 是 ArrayBuffer })responseType: arraybuffer这个字段很多人漏掉。不设的话某些基础库版本会尝试按默认类型处理导致分块数据异常。accept头写text/event-stream是给服务端一个明确信号方便后端判断走流式分支。success回调在流式场景下的触发时机要特别注意它是整个响应结束后才调用的不是每块调用。所以别把“流结束”的逻辑写在success里就以为能拿到中间数据中间数据全在onChunkReceived里。2.2 增量解码的正确写法解码这块我踩过最深的坑。最初图省事每块new TextDecoder().decode(chunk)结果中文偶尔出现乱码方块英文正常。原因就是跨块字符被切断。改成增量解码const decoder new TextDecoder(utf-8) let buffer task.onChunkReceived((res) { const text decoder.decode(res.data, { stream: true }) buffer text // 按协议分隔符切分完整消息 const parts buffer.split(\n\n) buffer parts.pop() // 最后一段可能不完整留到下一块 parts.forEach((part) { handleMessage(part) }) })这里有两个关键点。第一{ stream: true }让解码器保留不完整字节下一块自动续上。第二业务层也要做类似的“半包”处理——SSE 协议用\n\n分隔消息但一块数据可能只包含半条消息所以要用一个buffer暂存切出完整消息后再处理剩下的留到下次。提示流结束时记得调一次decoder.decode()不带参数把解码器内部残留的字节冲出来否则最后几个字符可能丢。2.3 消息解析与上屏节流拿到完整消息后解析成业务数据。SSE 格式一般是data: {...}这样的行解析时去掉前缀再JSON.parse。上屏这块有个性能细节如果服务端吐字很快每块都setData会触发频繁渲染页面会卡。我的做法是攒一小批再更新比如每 50ms 或每积累 20 个字符刷一次。let pending let timer null function appendText(text) { pending text if (timer) return timer setTimeout(() { this.setData({ content: this.data.content pending }) pending timer null }, 50) }setData本身有开销尤其是内容越来越长的时候整段重设的代价会上升。如果内容会到几千字考虑只更新变化的部分或者用富文本分片渲染。这个优化在长文档生成场景里差别很明显。3. 那些文档里不会写的坑跑通 demo 只是开始真上线后问题才一个个冒出来。这一节挑几个我实际遇到、且排查花了不少时间的坑把排查链路也还原一下方便你遇到类似现象时对照。3.1 页面卸载后连接还在跑内存和请求双泄漏现象是用户点了返回页面已经销毁但后台日志还在持续收到分块回调setData报“页面不存在”的警告。原因是wx.request返回的RequestTask不会因为页面卸载自动中断onChunkReceived的监听也还在。修复分两步。第一在onUnload里调task.abort()主动中断请求。第二把task和监听引用存到页面实例上卸载时清理。onUnload() { if (this.streamTask) { this.streamTask.abort() this.streamTask null } if (this.flushTimer) { clearTimeout(this.flushTimer) this.flushTimer null } }这里有个容易忽略的点abort()之后fail回调可能会触发错误信息类似“request:fail abort”。如果你的fail里做了错误提示用户会看到一个莫名其妙的报错弹窗。所以要在fail里判断错误信息主动中断的情况直接忽略。3.2 弱网下分块乱序与重复的假象有次测试反馈弱网环境下生成的内容偶尔出现句子重复或顺序错乱。第一反应是服务端并发问题查了日志发现服务端发送顺序是对的。问题出在客户端——我在onChunkReceived里做了异步处理比如先await一个格式化函数再拼接异步任务的完成顺序和回调触发顺序不一致导致拼接错位。排查链路是这样的先确认服务端发送顺序加序号日志确认无误后怀疑客户端在回调入口打时间戳和块序号发现回调是顺序的但拼接结果乱最后定位到回调内部有异步操作。修复很简单回调内保持同步拼接异步逻辑放到拼接之后或者用队列串行化处理。这个坑的教训是onChunkReceived的回调应该当成“同步收集器”只做解码和入队真正的业务处理放到队列消费端保证顺序。3.3 服务端 flush 不生效客户端一直等另一种常见现象代码没问题但客户端要等很久才一次性收到全部内容流式效果完全没出来。这多半是服务端或中间层的问题。排查顺序我一般这样走用命令行工具直接请求接口看是否逐块输出。如果命令行也是攒着一次性出问题在服务端或网关。检查服务端是否调用了 flush。很多框架默认有输出缓冲不手动 flush 不会下发。检查网关/代理是否开了缓冲。有些反向代理默认会缓冲响应体需要针对该路由关闭。检查响应头是否带了Transfer-Encoding: chunked以及有没有误设Content-Length设了长度就没法分块了。现象可能原因排查手段命令行也一次性出服务端未 flush查框架输出缓冲配置命令行逐块、小程序一次性客户端解码/拼接问题回调入口打日志部分环境一次性网关缓冲对比直连与经网关首块延迟大服务端首字节慢查模型/计算首块耗时这个表格我基本每次排查都会过一遍能快速缩小范围。3.4 基础库版本差异导致的行为不一致enableChunked在不同基础库版本上的表现有细微差别尤其是早期版本对ArrayBuffer的处理和onChunkReceived的触发频率。我遇到过在某个版本上分块回调合并得比较厉害几块合成一次回调在另一个版本上又很碎。这会影响你按块做节流的逻辑。应对办法是不要假设“一次回调等于服务端一次 flush”把回调当成“可能包含任意数量完整消息的字节片段”来处理用前面说的 buffer 切分逻辑兜底。同时在app.json里设一个合理的最低版本太老的版本直接走非流式降级。4. 断线、超时与降级的完整策略流式请求持续时间比普通请求长得多一次生成可能几十秒甚至几分钟网络中断、服务端超时、用户切后台的概率都高很多。这块不做扎实线上投诉会很多。4.1 超时设置要区分“连接超时”和“空闲超时”wx.request的timeout是整体超时流式场景下如果设得太短长生成会被掐断设得太长真断网了又要等很久才报错。我的做法是把timeout设成一个较大的值比如 60s然后在客户端自己做“空闲超时”检测每收到一块就重置一个计时器超过 N 秒没新数据就判定卡死主动abort并提示重试。let idleTimer null function resetIdleTimer() { clearTimeout(idleTimer) idleTimer setTimeout(() { task.abort() showRetry() }, 15000) // 15 秒无新数据视为卡死 }空闲超时比整体超时更贴合流式的实际语义——只要还在持续出数据就说明连接是活的。4.2 断线重连要不要做怎么做流式生成做到一半断了重连有两种思路一是从头重来二是断点续传。从头重来实现简单但浪费已生成内容用户体验也差断点续传需要服务端支持客户端把已收到的内容长度或消息序号带回去服务端从该位置继续。我的经验是短内容几秒内完成直接从头重来成本低长内容几十秒以上值得做续传但要服务端配合。续传的关键是客户端要记录“已确认收到”的位置且这个位置必须是完整消息边界不能是半个字符或半条消息否则续传会错位。4.3 降级路径必须提前设计不是所有用户环境都能跑流式基础库太老、企业网络限制、服务端某次发布出问题。降级路径要在设计阶段就定好而不是出事后再补。我的做法是接口层做一个开关流式失败能力不支持或连续失败时自动切到普通请求一次性拿完整结果UI 上从“逐字出”变成“转圈后整段出”用户至少能用。function requestWithFallback(params) { if (!canUseChunked()) { return normalRequest(params) } return streamRequest(params).catch(() normalRequest(params)) }降级时要注意别让用户感知到“两次请求”UI 状态要平滑过渡比如流式已经出了一部分再失败降级后应该保留已出内容并补齐剩余而不是清空重来。5. 性能与体验上的几个实操心得功能跑通、稳定性兜住之后剩下的就是打磨体验。这块没有标准答案但有几个方向投入产出比很高。5.1 首块时间比总时长更影响体感用户对“开始有反应”的敏感度远高于“全部完成”。所以优化重点应该放在首块延迟上服务端尽快吐出第一个 token 或第一段内容哪怕后面慢一点用户也觉得“它在动”。我一般会要求服务端在开始计算前先发一个心跳块或空数据块让客户端尽早进入“流式状态”UI 上先展示一个占位光标。5.2 渲染长内容的滚动策略内容越来越长时如果每次都自动滚到底部用户想往回看历史内容就会被强行拉回。我的处理是只有当用户当前处于“接近底部”的位置时才自动滚动用户手动上滑后暂停自动滚动并给一个“回到底部”的浮动按钮。这个细节在对话类场景里对体验影响很大。5.3 取消操作要真正中断不能只改 UI用户点“停止生成”不能只是把按钮状态改了、后台还在跑。要调task.abort()真正中断请求同时通知服务端停止计算如果服务端支持取消信号。只改 UI 的话服务端资源还在消耗长此以往成本会很难看。取消后已生成的内容保留允许用户基于已有内容继续操作。5.4 日志与埋点要覆盖流式特有指标普通请求看耗时和成功率就够了流式要多看几个指标首块延迟、块间隔分布、总块数、异常中断率、降级触发率。这几个指标能帮你快速判断问题出在服务端首字节、网络抖动还是客户端处理。我习惯在回调入口统一打点块序号、时间戳、字节数都记上出问题时日志一拉就能还原。流式响应在小程序里不算复杂但细节密度高每个环节都有它自己的脾气。把解码、拼接、清理、降级这几件事做扎实剩下的就是根据业务场景调优体感。我自己的习惯是先把最小闭环跑通再逐项加稳定性措施最后才做体验打磨——顺序反了容易在细节里迷路。