
1. 为什么要在 Cocos Creator 里手搓一个 WebSocket 聊天先把场景说清楚。你手上有一个 Cocos Creator 做的项目可能是个小游戏、一个互动房间、或者一个带社交属性的轻应用现在产品那边提了个需求玩家之间要能实时说话。不是那种发一条刷新一次的留言板而是消息发出去对面几乎同时就能看到。这时候你面前通常有三条路轮询、长连接、或者直接用第三方即时通讯服务。轮询最省事但延迟高、请求量大玩家发一句话要等一两秒才显示体验很差。第三方服务接入快但要么收费、要么有额外依赖、要么数据不在自己手里。所以大多数中小项目最后都会落到 WebSocket 上——它是浏览器和 Node.js 都原生支持的全双工通信协议握手一次之后连接一直保持双方随时可以推消息延迟基本就是网络往返的时间。这篇内容就是围绕“在 Cocos Creator 里用 WebSocket 做一个能跑起来的在线聊天”展开的。我会把服务端和客户端两边都写出来服务端用 Node.js 加ws这个库客户端用 Cocos Creator 的 TypeScript 脚本。整套东西不依赖任何第三方云服务你自己一台能跑 Node 的机器就能验证。适合谁看适合已经会用 Cocos Creator 做基本场景和脚本、但对网络通信还比较模糊的开发者也适合想快速搭一个联机原型、验证玩法的人。读完你至少能拿到一套可以直接抄的代码骨架知道每一步为什么这么写以及哪些坑我替你踩过了。需要提前说明的是下面所有代码都是基于常见实践补全的完整实现不是从某个现成项目里扒出来的你可以直接拿去改。服务端监听地址、端口这些参数我会给出选择理由不会甩一堆数字让你自己猜。2. 整体架构与方案选型拆解2.1 为什么是 WebSocket 而不是 HTTP 短连接先把这个“为什么”讲透不然你后面调参数的时候心里没底。HTTP 是请求-响应模型。客户端不发请求服务端就没法主动给你东西。聊天这个场景的本质是“别人说话的时候我要立刻知道”这是典型的服务端主动推送。用 HTTP 硬做只能让客户端每隔几百毫秒问一次“有新消息吗”这就是轮询。轮询的问题有三个一是延迟取决于轮询间隔间隔短了请求量爆炸间隔长了消息就慢二是每次请求都要带一堆头部有效数据占比很低三是连接频繁建立断开服务端压力大。WebSocket 在握手阶段借用了 HTTP 的升级机制一旦升级成功这条 TCP 连接就变成全双工通道。之后双方发的每一帧数据都只有很小的协议开销没有重复的头部。对于聊天这种“消息小而频繁”的场景这是最合适的。你可以把它理解成HTTP 是寄信每封信都要写完整地址WebSocket 是打电话接通之后直接说话。2.2 服务端为什么选 Node.js 加 ws服务端语言选择很多Java、Go、Python 都能做。我这里选 Node.js 加ws库理由很实际一是ws是目前 Node 生态里最成熟、最轻量的 WebSocket 实现装完就能用不需要额外框架二是 Node 的事件驱动模型天然适合处理大量并发长连接一个连接就是一个事件源代码写起来很直观三是前后端都用 JavaScript/TypeScript心智负担小你调试的时候不用在两套语言思维之间切换。如果你项目后期要上生产可以考虑在ws外面套一层更完整的框架但对“简单在线聊天”这个目标来说ws足够了多引入一层反而增加理解成本。2.3 客户端在 Cocos Creator 里怎么接Cocos Creator 的脚本运行环境在 Web 平台下就是浏览器环境WebSocket是全局对象直接用就行。在原生平台比如打包成手机 AppCocos 也提供了对应的 WebSocket 支持API 基本一致。所以客户端这边我们不需要引入任何额外库用引擎自带的WebSocket就行。这里有个关键决策消息格式用什么。我强烈建议用 JSON 字符串。原因很简单——可读、好调试、跨语言。你抓包或者打日志的时候一眼就能看出消息内容。二进制协议虽然省流量但对一个聊天原型来说省的那点带宽远不如调试便利重要。等你的消息量大到 JSON 成为瓶颈了再考虑换二进制也不迟。2.4 消息协议怎么设计才不容易乱聊天系统的消息类型会越来越多登录、发消息、系统通知、用户列表更新、心跳……如果不提前约定好格式代码很快就会变成一堆 if-else。我的做法是定一个统一的消息信封所有消息都长这样{ type: chat, data: { from: 用户A, content: 大家好 } }type字段决定这条消息是什么类型data里放具体内容。服务端和客户端都根据type来分发处理。这样加新消息类型的时候只需要加一个分支不会影响已有逻辑。这个设计看起来简单但它能帮你省掉后面大量的重构时间。3. 服务端实现细节与关键代码3.1 环境准备与依赖安装先建一个空目录初始化 Node 项目mkdir chat-server cd chat-server npm init -y npm install wsws装完之后你的package.json里会多一条依赖。这里不需要装 express 之类的 Web 框架因为 WebSocket 服务本身不依赖 HTTP 路由。如果你想让服务端同时提供 HTTP 接口比如健康检查可以再装但聊天功能用不上。提示Node 版本建议 14 以上ws的新版本对低版本 Node 支持不好。用node -v确认一下。3.2 创建 WebSocket 服务并管理连接服务端的核心任务是三件事接受连接、维护连接列表、广播消息。先看基础骨架const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); // 用一个 Map 保存所有在线连接key 是连接对象value 是用户信息 const clients new Map(); wss.on(connection, (ws) { console.log(有新连接接入); ws.on(message, (raw) { // 处理消息 }); ws.on(close, () { // 清理连接 }); }); console.log(WebSocket 服务已启动监听 8080 端口);端口选 8080 是个习惯问题你也可以用 3000、9000只要不和系统其他服务冲突就行。生产环境通常会在前面放一层反向代理由代理来转发 WebSocket 请求那时候服务端监听本地端口即可。为什么用Map而不是数组来存连接因为断开连接的时候你需要快速找到并删除对应的记录。数组删除是 O(n)Map 是 O(1)。在线人数一多这个差别就出来了。而且 Map 可以顺便存用户昵称、加入时间等信息后面做用户列表很方便。3.3 消息广播与用户上下线通知广播的逻辑是遍历所有连接逐个发送。但要注意发送之前必须检查连接状态因为可能有的连接正在关闭function broadcast(type, data, excludeWs null) { const payload JSON.stringify({ type, data }); for (const [ws, user] of clients) { if (ws excludeWs) continue; if (ws.readyState WebSocket.OPEN) { ws.send(payload); } } }readyState这个检查非常关键。我见过太多新手直接ws.send()结果连接已经断了程序直接抛异常。WebSocket.OPEN是常量 1表示连接正常。只有这个状态下发送才是安全的。用户上线和下线都要广播这样所有人的用户列表才能保持同步。上线的时候先给新用户回一条“欢迎”消息再广播“某某加入了”下线的时候从 Map 里删掉然后广播“某某离开了”。3.4 心跳检测别让死连接占着位置这是服务端最容易忽略、但上线后最容易出问题的地方。TCP 连接有时候会因为网络波动、客户端异常退出而变成“半开”状态——服务端以为还连着实际上对面早就没了。这种连接不清理会越积越多最后内存爆掉。解决办法是心跳。服务端定期给每个连接发一个 ping客户端收到后回 pong。如果某个连接连续几次没回就判定它死了主动关掉。const HEARTBEAT_INTERVAL 30000; // 30 秒 const heartbeatTimer setInterval(() { for (const [ws] of clients) { if (ws.isAlive false) { ws.terminate(); clients.delete(ws); continue; } ws.isAlive false; ws.ping(); } }, HEARTBEAT_INTERVAL); wss.on(connection, (ws) { ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); });30 秒这个间隔是我实测下来比较稳的值。太短了浪费资源太长了发现死连接不及时。ws.ping()发的是 WebSocket 协议层面的控制帧不是业务消息客户端不需要写代码处理浏览器和 Cocos 底层会自动回 pong。这一点很多人不知道以为要自己手动回其实不用。注意ws.terminate()是强制断开不发送关闭帧ws.close()是优雅关闭。清理死连接用terminate()更干脆。4. 客户端在 Cocos Creator 中的完整实现4.1 建立连接与断线重连客户端这边我建议单独写一个网络管理脚本不要把所有逻辑塞进某个 UI 组件里。这样后面加功能、换界面都不用动网络层。import { _decorator, Component } from cc; const { ccclass } _decorator; ccclass(ChatClient) export class ChatClient extends Component { private ws: WebSocket null; private serverUrl: string ws://127.0.0.1:8080; private reconnectTimer: number 0; private isManualClose: boolean false; connect() { this.isManualClose false; this.ws new WebSocket(this.serverUrl); this.ws.onopen () { console.log(连接成功); this.send(login, { name: 玩家 Math.floor(Math.random() * 1000) }); }; this.ws.onmessage (event) { const msg JSON.parse(event.data); this.handleMessage(msg); }; this.ws.onclose () { console.log(连接断开); if (!this.isManualClose) { this.scheduleReconnect(); } }; this.ws.onerror (err) { console.error(连接出错, err); }; } }serverUrl这里写的是本机地址你实际部署的时候换成服务器地址。注意协议是ws://如果服务端上了 TLS就换成wss://。断线重连是必须做的。移动网络下切后台、进电梯、信号弱连接断掉太常见了。重连策略我用的是固定间隔加一点随机避免所有客户端同时重连把服务端打崩private scheduleReconnect() { if (this.reconnectTimer) return; const delay 3000 Math.random() * 2000; this.reconnectTimer setTimeout(() { this.reconnectTimer 0; this.connect(); }, delay); }3 到 5 秒的随机延迟实测能有效错开重连高峰。如果你想要更精细的策略可以做指数退避第一次 1 秒第二次 2 秒第三次 4 秒但记得设一个上限不然断网久了要等很久。4.2 消息收发与类型分发发送消息统一走一个方法把 type 和 data 打包send(type: string, data: any) { if (!this.ws || this.ws.readyState ! WebSocket.OPEN) { console.warn(连接未就绪消息发送失败); return; } this.ws.send(JSON.stringify({ type, data })); }接收消息按 type 分发private handleMessage(msg: any) { switch (msg.type) { case chat: this.onChatMessage(msg.data); break; case userList: this.onUserListUpdate(msg.data); break; case system: this.onSystemMessage(msg.data); break; default: console.warn(未知消息类型, msg.type); } }这个 switch 就是整个客户端的消息中枢。以后加新类型在这里加一个 case 就行。我习惯把每个 case 的处理逻辑单独写成方法这样 switch 本身保持清爽一眼能看全所有消息类型。4.3 把消息接到 UI 上网络层写好了接下来是界面。假设你场景里有一个ScrollView用来显示消息列表一个EditBox输入内容一个Button发送。消息列表的更新要注意一点不要每来一条消息就重建整个列表那样消息一多会卡。正确做法是往列表里追加一个节点。如果你用的是ScrollView加Layout追加完记得让滚动条自动滚到底部private appendMessage(text: string) { const item instantiate(this.msgItemPrefab); item.getComponent(Label).string text; this.contentNode.addChild(item); // 滚动到底部 this.scrollView.scrollToBottom(0.1); }scrollToBottom的第二个参数是滚动时间给个 0.1 秒有个平滑效果比瞬间跳过去体验好。这个细节很多人不注意但玩家能感觉到。发送按钮的回调里先做空内容校验再调用send最后清空输入框onSendClick() { const text this.editBox.string.trim(); if (!text) return; this.send(chat, { content: text }); this.editBox.string ; }提示EditBox在移动端弹出键盘时会顶起界面记得处理一下输入框的位置不然键盘会挡住输入内容。这是移动端聊天界面的经典坑。5. 实操中踩过的坑与排查技巧5.1 连接建立失败怎么查最常见的原因是地址写错或者服务端没起来。排查顺序是这样的先在浏览器里开一个空白页按 F12 打开控制台手动执行new WebSocket(ws://127.0.0.1:8080)看能不能连上。如果这里就报错说明是服务端问题去检查服务端进程是否在跑、端口是否被占用。如果浏览器能连上但 Cocos 里连不上那多半是打包平台的问题。原生平台对ws://明文连接有安全限制某些系统版本会直接拒绝。解决办法是服务端上 TLS客户端用wss://。这个坑我在原生打包时踩过Web 平台好好的一打包到手机就连不上查了半天才发现是明文连接被拦了。5.2 消息发出去了对面收不到先确认服务端的广播逻辑有没有把发送者自己排除掉。我上面给的broadcast有个excludeWs参数如果你传了发送者自己那发送者就看不到自己发的消息。聊天场景通常需要发送者也看到自己的消息显示在右侧所以广播时不要排除自己或者客户端本地先追加一条。另一个可能是消息格式不对。服务端JSON.parse失败会抛异常如果你没做 try-catch整个消息处理就中断了。建议在message事件里包一层ws.on(message, (raw) { let msg; try { msg JSON.parse(raw); } catch (e) { console.error(消息格式错误, raw); return; } // 正常处理 });5.3 常见问题速查表现象可能原因排查方向连接一直转圈连不上地址错误或服务端未启动浏览器控制台手动测试连接连上后立刻断开服务端异常或协议不匹配看服务端日志有无报错消息延迟很高网络问题或服务端阻塞检查服务端是否有同步耗时操作部分用户收不到消息连接状态未检查确认发送前判断 readyState长时间挂机后失联缺少心跳机制补上 ping/pong 心跳移动端切后台断连系统回收连接监听 onclose 做重连5.4 几个提升稳定性的实操心得第一服务端所有对连接的遍历操作都要考虑“遍历过程中连接被删除”的情况。JavaScript 的 Map 在遍历时删除元素是安全的但如果你用的是数组边遍历边 splice 就会漏掉元素。这是很多人写广播时容易犯的错。第二客户端重连成功后要重新发送登录消息让服务端重新登记用户信息。否则服务端那边这个连接是“匿名”的用户列表会乱。我一般把登录逻辑放在onopen里这样每次重连都会自动重新登录。第三消息内容要做长度限制。不加限制的话有人发一条几兆的文本服务端广播出去能把所有客户端卡死。服务端收到消息后先判断长度超过阈值就丢弃或者截断。这个阈值我一般设 500 个字符聊天场景足够了。第四开发阶段把收发的原始消息都打上日志方便对照。上线前再把日志级别调高避免影响性能。这个习惯能帮你省下大量调试时间。6. 后续可以怎么扩展这套骨架跑通之后往上加东西就很顺了。想支持私聊就在消息里加一个to字段服务端根据to找到对应连接单独发送而不是广播。想支持聊天房间就在连接上挂一个roomId广播时只发给同房间的连接。想存历史消息就在服务端接一个轻量数据库用户登录时把最近若干条推给他。我个人在实际操作中的体会是聊天功能真正的难点不在“怎么把消息发出去”而在“怎么在各种异常情况下保持连接可用”。网络抖动、切后台、服务端重启这些才是吃掉你大部分调试时间的地方。所以一开始就把重连、心跳、状态检查这些基础设施做扎实后面加功能会轻松很多。反过来如果一开始图省事跳过这些等用户量上来再补改动量会大得多。最后分享一个小技巧调试的时候可以同时开两个浏览器标签页各自连同一个服务端互相发消息验证。这比在 Cocos 里反复打包测试快得多因为浏览器改代码刷新就行不用等引擎编译。等逻辑验证通了再回到 Cocos 里接 UI效率能翻倍。