
1. 为什么要在微信小程序里折腾TCP先说结论微信小程序官方提供的网络API里wx.request是HTTP短连接wx.connectSocket是WebSocket两者加起来能覆盖90%以上的日常业务场景。剩下的10%就是TCP出场的时候。我最初接触到这个需求是做一个生产设备监控的小程序。设备端用的是工业网关网关对外只暴露TCP端口协议是自定义的二进制报文压根没有HTTP服务。现场工程师希望直接在手机上通过小程序查看设备实时状态、下发控制指令。当时的第一反应是“小程序能不能连TCP”查了一圈发现从基础库2.9.0开始微信提供了wx.createTCPSocket这个API但文档写得比较简略社区里相关的实战资料也比较零散。这篇内容的目标读者是那些已经写过小程序、熟悉wx.request但没碰过wx.createTCPSocket的开发者。如果你要做的事情属于下面几类这篇内容可以直接照着用连接局域网内的设备比如PLC、传感器网关、串口服务器、智能硬件对接老系统的自定义TCP协议服务端不方便改成HTTP或WebSocket需要跟云端保持长连接、但不想走WebSocket的场景在uniapp环境下做跨端TCP通信需要先泼一盆冷水小程序的TCP能力是受限的它并不是一个完全自由的socket实现。iOS上不支持客户端向任意IP直连只能连配置了合法域名的服务器域名解析出来的IP可以Android端稍微宽松一些允许填IP地址。另外TCP数据是二进制流小程序侧要处理好ArrayBuffer和编码问题否则很容易出现乱码或者数据错位。这些坑我在后面的章节里会逐个展开。先说一句总结如果你想在小程序里做TCP通信方向上是可行的但必须弄清楚API的行为边界、数据收发模型和平台差异否则上线之后会被线上问题打得措手不及。2. 小程序TCP通信能做什么——能力边界与典型场景2.1 哪些场景必须用TCP而不是HTTPHTTP本质上是“请求-响应”模式客户端发一次请求服务端返回一次结果连接通常一次用完就断。这种模式适合页面加载、表单提交、查询数据这类低频交互。但下面这些场景HTTP会显得力不从心服务端主动推送数据HTTP只能客户端轮询服务端没法主动把数据“塞”给客户端。设备状态变化、告警信息、实时位置更新如果用轮询延迟高不说流量和电量的浪费也很严重。自定义二进制协议很多工业设备、IoT网关、金融终端走的都是私有协议。协议里可能有CRC校验、位运算标记、按bit取数据等逻辑这些在HTTP的文本世界里基本没法直接跑必须在业务层再套一层二进制解析。局域网直连设备设备就摆在同一个WiFi环境下IP是192.168.x.x没有公网地址没法跑HTTP服务。这时候必须局域网内直连TCP端口。低延迟交互用户在手机上按一个按钮指令要毫秒级到达设备并返回结果。TCP长连接省去了三次握手、TLS握手、HTTP头部传输的开销延迟会低很多。2.2 小程序TCP能力的三条限制线第一个限制是合法域名校验。出于平台安全策略小程序所有网络请求都要求域名是HTTPS且在小程序后台配置过白名单。但wx.createTCPSocket创建的TCP连接不受这个限制也不校验域名白名单。这是一个好消息也是一个坏消息。好消息是开发调试方便本地IP直连也可以跑坏消息是发布上线时如果连的是IP地址Android还能跑iOS上大概率会被系统拦下来必须换成有域名的服务器并且在后台配置socket合法域名。第二个限制是只能作为客户端不能监听端口。小程序没有listen能力只能主动去连别人的端口。这意味着小程序不可能作为一个TCP服务端去接收外部设备的连接请求只能由小程序主动发起连接服务端必须先启动并处在监听状态。第三个限制是数据必须走ArrayBuffer。TCP是流式传输小程序收到的数据以二进制格式取回。你拿到的不是一个现成的字符串而是ArrayBuffer需要自己处理字节序、编码、分包等逻辑。很多人第一次用的时候会在这里卡住拿到的是一堆乱码其实是没做类型转换。下面这张表格总结了wx.createTCPSocket的核心接口方便你快速核对接口作用关键点wx.createTCPSocket()创建TCP socket实例返回TCPSocket对象socket.connect({address, port})发起连接TCP三次握手在这里发生socket.write(data)发送数据参数需为ArrayBuffersocket.onMessage(callback)接收数据回调参数为ArrayBuffersocket.onConnect(callback)连接成功回调触发后可开始收发socket.onError(callback)错误处理网络断开、连接失败都会触发socket.onClose(callback)连接关闭服务端关闭或客户端主动关闭socket.close()主动关闭连接需要自己清理监听器3. 核心API与通信原理从三次握手到数据收发3.1 连接建立的背后迷茫的三次握手wx.createTCPSocket在底层走的是标准的TCP协议栈。当我们调用socket.connect({address: 192.168.1.100, port: 9000})时系统会自动完成TCP三次握手客户端发送SYN包请求建立连接服务端收到后返回SYNACK包表示“你的请求我收到了我也准备好了”客户端再回复ACK包连接正式建立这个过程对开发者是透明的你只需要监听onConnect事件触发后就可以确定连接已经建立。有一个细节要注意onConnect触发后TCP连接的发送缓冲区已经打开但接收缓冲区可能还在等待数据。此时如果你立刻调用write方法发送数据数据会先缓存在系统缓冲区服务端只要一有回应onMessage就会立即回调。3.2 发送数据传字符串前一定要先转ArrayBuffer刚接触这个API的时候我犯过一个很蠢的错误直接调用socket.write(hello)。控制台报错信息提示参数类型必须是ArrayBuffer。后来我意识到TCP是一个面向字节流的协议所有的数据在传输层都是二进制的应用层的字符串必须是编码后的字节序列。那么问题来了微信小程序JS里怎么把字符串转成ArrayBuffer最简单的方式是使用TextEncoder在基础库2.16.1以上版本部分环境下可以直接用或者用Uint8Array手动编码UTF-8字节。下面这段代码演示了完整的发送流程function stringToArrayBuffer(str) { // 将字符串转换为UTF-8编码的字节数组 const encoder new TextEncoder(); return encoder.encode(str).buffer; } const socket wx.createTCPSocket(); socket.onConnect(() { const buf stringToArrayBuffer(hello tcp server); socket.write(buf); });如果你的设备协议是ASCII编码可以走更简单的charCodeAt方案如果是GBK编码那就有点挠头了小程序侧的TextEncoder不支持GBK你得自己手写编码表或者先让人把服务端的协议改成UTF-8。我建议所有新协议的对接一律用UTF-8省掉一堆跨平台编码的麻烦。3.3 接收数据ArrayBuffer不是字符串onMessage回调返回的是ArrayBuffer如果你直接把res.message赋值给data然后塞进this.setData页面模板渲染出来大概率是一堆undefined或者乱码。需要做一步转换。如果是文本协议可以直接用TextDecoder解码socket.onMessage(res { const decoder new TextDecoder(utf-8); const text decoder.decode(res.message); console.log(收到的文本, text); });如果是二进制协议就需要用DataView按字节解析。比如协议里有一个2字节的CRC校验字段低位在前你可以这样取socket.onMessage(res { const view new DataView(res.message); const crc view.getUint16(0, true); // true表示小端字节序 console.log(CRC值, crc); });我把通用流程汇总成了这样一张决策表数据类型处理方式适用场景纯文本UTF-8TextDecoder解码为字符串JSON协议、行文本协议二进制结构DataView按偏移量读字段自定义报文、设备私有协议混合头部二进制正文文本先按头部长度取字段再解码剩余带帧头和载荷的封装协议3.4 断开连接与资源回收TCP连接的关闭涉及四次挥手。在小程序里你不需要手动处理挥手过程调socket.close()之后系统自动完成。但有一个经常被忽略的问题不销毁的socket监听器会积累内存泄漏。我在开发阶段反复连接断开发现页面切走再回来老连接还在跑。原因是每次创建socket都注册了onMessage回调页面销毁时没移除。正确做法是在页面onUnload或组件销毁阶段调用onUnload() { if (this.tcpSocket) { this.tcpSocket.onMessage(() {}); this.tcpSocket.onClose(() {}); this.tcpSocket.onError(() {}); this.tcpSocket.close(); this.tcpSocket null; } }4. 从能跑到能用封装一个带心跳和断线重连的TCP客户端如果只是在开发工具里跑通一次连接那这篇文章到这里就够用了。但真实场景中网络切换、后台切回、服务端重启都会导致连接中断。一个健壮的小程序TCP客户端至少要解决三个问题自动重连、心跳保活、数据分发。4.1 设计思路与模块划分我按照“连接管理”和“业务收发”两层来设计。连接管理层负责socket的创建、连接、断开、重连、心跳业务收发层负责把收到的数据解析成业务事件派发给页面。这样页面里不用关心网络细节只处理业务逻辑。整个类的大致结构如下connect()创建socket并连接disconnect()主动断开并清理资源reconnect()延迟重连带最大重试次数send(data)对外发送接口自动转ArrayBufferonMessage(callback)业务层数据回调startHeartbeat()定时发送心跳包stopHeartbeat()清空心跳定时器4.2 核心代码实现class TcpClient { constructor(options) { this.address options.address; this.port options.port; this.heartbeatInterval options.heartbeatInterval || 10000; this.reconnectDelay options.reconnectDelay || 3000; this.maxReconnectTimes options.maxReconnectTimes || 5; this.reconnectTimes 0; this.socket null; this.connected false; this.messageHandler null; this.heartbeatTimer null; this.reconnectTimer null; } connect() { this.socket wx.createTCPSocket(); this.socket.onConnect(() { console.log(TCP连接成功); this.connected true; this.reconnectTimes 0; this.startHeartbeat(); }); this.socket.onMessage(res { // 收到数据先抛给业务层 if (this.messageHandler) { this.messageHandler(res.message); } }); this.socket.onError(err { console.error(TCP错误, err); this.connected false; this.stopHeartbeat(); this.handleReconnect(); }); this.socket.onClose(() { console.log(TCP连接关闭); this.connected false; this.stopHeartbeat(); this.handleReconnect(); }); this.socket.connect({ address: this.address, port: this.port }); } send(data) { if (!this.connected) { console.warn(连接未建立无法发送数据); return false; } const buffer typeof data string ? this._encodeString(data) : data; try { this.socket.write(buffer); return true; } catch (e) { console.error(发送失败, e); return false; } } onMessage(callback) { this.messageHandler callback; } startHeartbeat() { if (!this.heartbeatInterval) return; this.heartbeatTimer setInterval(() { // 心跳包内容根据协议定义这里以最简单的字符串为例 this.send(ping); }, this.heartbeatInterval); } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer null; } } handleReconnect() { if (this.reconnectTimes this.maxReconnectTimes) { console.error(重连次数超限); return; } this.reconnectTimes; console.log(准备第${this.reconnectTimes}次重连); this.reconnectTimer setTimeout(() { this.connect(); }, this.reconnectDelay); } disconnect() { this.stopHeartbeat(); if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer null; } if (this.socket) { this.socket.close(); this.socket null; } this.connected false; } _encodeString(str) { const encoder new TextEncoder(); return encoder.encode(str).buffer; } } module.exports TcpClient;4.3 在页面中使用在页面里引入这个类整体使用方式很清爽const TcpClient require(../../utils/tcp-client.js); Page({ onLoad() { this.client new TcpClient({ address: 192.168.1.100, port: 9000 }); this.client.onMessage(this.handleMessage.bind(this)); this.client.connect(); }, handleMessage(buffer) { const view new DataView(buffer); // 解析业务数据更新页面 const temp view.getUint8(0); this.setData({ temperature: temp }); }, onUnload() { this.client.disconnect(); } });实测下来这个封装的稳定性比裸用socket要好很多。特别是心跳机制它解决了一个很隐蔽的问题很多路由器/防火墙会自动回收长时间空闲的TCP连接你表面上看连接还在实际上服务端已经收不到任何数据了。心跳包定期刷存在感能有效防止这种静默断连。4.4 心跳周期怎么定心跳周期不是拍脑袋定的。我一般这样估算先在服务端日志间隔1分钟、2分钟、5分钟压测找到连接被网络中间设备回收的时间点然后取这个时间的一半作为心跳间隔。比如你发现连接在4分钟时被回收心跳间隔就设2分钟。太频繁会浪费流量和电量太少起不到保活作用。5. 实战中踩过的坑排查与绕过方案每个人写TCP通信都会踩坑我在这里把最有代表性的几个问题和排查过程完整列出来这些是文档里不太会写的内容。5.1 连接不上真机和开发者工具的行为差异我在开发者工具里连本地TCP服务端一切正常一上真机就失败。后来查了很多资料才搞明白开发者工具运行在PC上网络环境宽松真机上iOS对TCP连接做了严格限制之前提过的合法域名问题在这里就会爆发。排查链路是这样的先用Android真机测试如果Android能连上iOS连不上基本可以确定是域名白名单问题检查小程序后台的“socket合法域名”是否配置且必须是wss://格式吗不是这里填的是tcp://格式其实不是文档写的是socket合法域名要填wss://但TCP不走这个配置iOS上能不能连纯属系统行为没法绕过去最稳妥的方案是让服务端挂一个域名小程序连域名而不是连IP这个问题至今没有一个透明的官方说明属于平台策略灰色地带。我的建议是正式上线前务必真机测试开发阶段用Android真机或者开发者工具排查业务逻辑最后再处理iOS的兼容。5.2 收到的数据是“半包”或“多包”TCP是流式协议它不像HTTP那样有清晰的消息边界。服务端发送一条完整的消息时你可能在第一次onMessage里只收到了一半也可能一次性收到好几条消息粘在一起。这就是教科书里常说的“粘包与半包”问题。我刚对接协议时设计了一个消息头带长度的协议服务端每次发送是这样前4字节消息体长度小端字节序后续字节消息体内容那么小程序收到数据后必须做缓冲拼接和长度判定。用一个数组缓存收到的数据块每次onMessage触发时先判断缓存数据是否足够4字节取出长度字段再判断数据是否足够完整的一帧。如果不够就等下一块如果多了就拆出完整消息剩余部分留到下次继续处理。下面是一个简化的解析逻辑class FrameAssembler { constructor() { this.buffer []; this.frameLength -1; } push(chunk) { this.buffer.push(...new Uint8Array(chunk)); return this.tryExtract(); } tryExtract() { const frames []; while (true) { // 还不足以读取长度字段 if (this.buffer.length 4) break; const view new DataView(new Uint8Array(this.buffer.slice(0, 4)).buffer); this.frameLength view.getUint32(0, true); if (this.buffer.length - 4 this.frameLength) break; // 取帧体 const body this.buffer.slice(4, 4 this.frameLength); frames.push(new Uint8Array(body).buffer); this.buffer this.buffer.slice(4 this.frameLength); } return frames; } }这段代码解决了80%的粘包场景。如果消息太碎onMessage触发过于频繁可以在push之前加一个小的合并等待或者用setTimeout将同一次事件循环内的数据合并处理。实测中我并没有遇到性能瓶颈小程序的onMessage频率一般不会爆炸。5.3 页面切后台再回去连接就断了小程序切到后台系统资源被回收TCP连接大概率会断开。切回来时如果你没有做重连页面就只能一直卡在离线状态。我的处理方式是监听App层面的onHide和onShow事件。在onHide里记录断开时间在onShow里判断如果连接已经断了就重新调用connect()。前端页面不用改太多代码只要在页面栈里保留同一个客户端实例即可。实际开发中我遇到过一种更隐蔽的情况onShow触发时TCP状态显示还是connected但服务端已经重启了发数据不会报错但服务端不再响应。这时靠心跳机制的超时判断来兜底连续3次心跳发出去没有收到任何回复强制把连接置为断开并重连。5.4 收到数据更新不了界面onMessage回调里执行this.setData页面有时候会偶发不刷新。排查后发现当onMessage频率过高时连续多次setData会造成界面卡顿或丢帧。TCP的实时性让数据变得密集比如设备以100ms间隔上报一次状态每秒钟就是10次setData这在小程序的渲染机制下太奢侈了。解法有两个看你的实时性要求选数据节流每500ms合并一次数据展示最新值使用wxs的响应式能力把数据放到WebView侧的变量里利用wxs处理二进制解析和文本转换再通过setData一次性更新对于大多数业务来说500ms的节流完全够用。实时性要求高的可以走WebSocket小程序同层渲染那套方案但TCP协议自身就决定了它有网络延迟太高的刷新频率意义不大。6. 粘包之外的排错进阶二进制协议设计与调试经验6.1 协议设计建议一字节的标记比什么都重要对接TCP服务端时经常遇到服务端协议是“先发一个帅气的JSON字符串后面再跟几字节的二进制标记”这种混乱设计。这种设计在小程序端解析起来相当痛苦因为你无法确定字节偏移量。我参与过的项目中一个干净可控的二进制协议至少应该包含帧头标记固定1~2字节比如0xAA 0x55用于快速定位帧起始位置长度字段2~4字节固定用大端或小端建议用大端Java系服务端默认大端命令字1字节或2字节区分协议类型载荷变长字段存JSON字符串或二进制数据校验CRC16或简单异或保证数据完整性举一个我实际用的协议帧格式| 帧头(2B) | 长度(2B) | 命令字(1B) | 数据段(nB) | 校验(2B) | | 0xAA 0x55 | 数据段长度 | 例如0x01查询 | JSON字符串 | CRC16 |这样的协议好处是解析器和业务完全分离加字段不需要改解析框架。即使服务端做二次开发小程序侧改动也小。6.2 用网络调试助手验证协议开发阶段我强烈建议先用PC上的网络调试助手模拟服务端把协议先跑通再联调真实设备。这个工具能在本地监听TCP端口自定义回复内容。基本流程PC开启TCP Server端口设置9000小程序connect到PC的局域网IP小程序send一个请求网络调试助手能看到十六进制内容在调试助手里输入预设的响应内容观察小程序能否正确解析这样联调的好处是能快速定位问题到底出在协议定义、字节序还是小程序解析算法上。因为你完全控制了服务端的回复内容可以一条命令一条数据地测试。6.3 常见的字节序翻车现场二进制协议里字节序不对是最容易翻车的点。很多服务端开发使用Java的DataOutputStream写short/int时默认大端序而Java里接收也是默认大端。如果你在小程序侧用DataView.getUint16(offset, false)那是大端序对应上了如果用DataView.getUint16(offset, true)小端序会读出完全不同的数值。我遇到过一个实际问题服务端上报一个16位温度值正常20度解析出来变成5120度。排查了半天最后用Log打印原始字节发现服务端写入的是大端我读的时候用了小端。改一个参数世界清净了。所以在设计协议时我建议在文档第一行写清楚“所有整数字段统一使用大端字节序网络字节序”然后大家都在这个前提下开发。调用DataView时统一传false少踩坑。7. 长连接还是短连接架构选择与资源考量7.1 长连接与短连接的取舍表维度短连接长连接建立连接开销每次请求三次握手连接频繁建立销毁建立一次复用但需维护状态资源占用客户端服务端临时占用释放快需持续占用文件描述符、内存服务端推送无法服务端主动推支持实时推送小程序适用性临时请求数据可用但不够实时监控、指令下发必备典型场景查询一次状态、一次配置下载设备实时监控、消息推送短连接用在小程序里其实和wx.request差不多没有额外优势。真正需要TCP长连接的场景一定是对实时性有要求的。7.2 长连接资源的两个隐患长连接无限重连会导致两个问题服务端连接数被打爆如果小程序端短时间内反复重连服务端由于没有及时释放半开连接可能会出现大量TIME_WAIT状态的连接占满端口。小程序内存占用每个socket实例都有对应的系统缓冲区和JS对象不断创建而不清理会造成内存持续上涨。我建议在代码里加一个信号量控制同一时间最多只保留一个socket连接。重连前先主动close旧连接再创建新连接避免因上一次连接未释放导致连接失败。7.3 从TCP到设备控制的完整流程最后以一个真实的设备控制场景收束全篇说明这整套逻辑在工程里怎么串起来的页面加载后检查wx.getStorageSync(device_ip)拿到网关地址创建TcpClient实例开启心跳发出查询指令命令字0x01数据段为空onMessage收到设备状态帧解析温度、湿度、开关状态用户在页面滑动滑块调整目标温度发送命令字0x02数据段为数值设备响应后页面对应控件显示“已生效”这个流程我实际跑下来从建立连接到收到第一帧数据局域网环境下大概20ms左右。用户感知完全无延迟。而如果用HTTP轮询最短也要500ms的轮询间隔体验差距非常大。我在做这个项目时最大的感受是小程序TCP通信没有想象的那么复杂但确实比wx.request多了一层对二进制流和连接生命周期的理解成本。把这篇文章里提到的API行为、封装思路、避坑经验消化掉你已经能处理绝大多数小程序TCP通信的实际需求了。最后一个值得提醒的小技巧开发调试时把wx.createTCPSocket的日志开关打开在小程序开发工具的控制台里可以直接看到底层的网络调用日志定位连接层问题会快很多。真机上没有这个日志所以尽量把错误和重连事件全部上报到自己的日志服务线上排查的时候会感激自己当初多写了一行日志。