
简介这是一套基于ThinkPHP6与Swoole构建后端、UniApp开发前端并整体仿QQ的即时通讯项目源码工程面向具备一定PHP与前端基础、希望深入理解即时通讯架构的开发者可用于毕业设计、课程设计、大作业、工程实训及大创竞赛等场景也适合作为学习练手或二次开发的起点。资源包共约2000个文件压缩后约89.04MB以1491个js脚本、111个vue组件、91个json配置、302个md说明文档为主另含少量css、txt与doc文件覆盖前后端源码、工程配置与使用说明结构完整便于按模块查阅。目前已有37人学习关注。项目代码经过测试运行功能可用可实现复现复刻读者可借鉴其即时通讯核心逻辑、Swoole长连接处理与UniApp跨端界面实现并在此基础上扩展新功能设计报告亦可参考遇到问题可联系作者获取解答与相关学习资料。1. 从零搭一套仿 QQ 的即时通讯ThinkPHP6 Swoole uni-app 到底能跑多远很多人第一次听到「ThinkPHP6 Swoole uni-app 做仿 QQ 即时通讯」第一反应是「这不就是个 CRUD 加个 WebSocket 吗」。真动手才知道消息时序、离线补偿、多端同步、心跳保活、群聊扩散每一项都能让一个没踩过坑的团队卡上两三个月。这个组合的价值在于后端用 ThinkPHP6 做业务接口和后台管理Swoole 扛长连接和消息推送前端用 uni-app 一套代码覆盖 H5、微信小程序、Android、iOS 甚至鸿蒙开发成本压得很低。它适合中小团队快速验证 IM 产品也适合个人开发者拿来做技术纵深。但「能跑」和「能上线」之间隔着一条河这篇就把这条河上的桥怎么搭、哪里会塌一次讲清楚。2. 后端选型为什么是 ThinkPHP6 管业务、Swoole 管长连接2.1 两者分工的边界在哪里ThinkPHP6 本身是同步阻塞的 PHP 框架一个请求一个进程处理登录、注册、好友关系、群资料、历史消息拉取这类「短连接、有事务、要落库」的业务非常顺手。Swoole 则是常驻内存的异步并发扩展它把 PHP 从「请求来了才启动」变成「进程一直活着等事件」天生适合 WebSocket 长连接、消息广播、定时任务。常见做法是让两者各管一摊HTTP 接口走 ThinkPHP6 的 FPM 或 Swoole HTTP ServerWebSocket 连接走独立的 Swoole WebSocket Server。两者通过 Redis 的发布订阅或者内部 TCP 端口通信。不要让 ThinkPHP6 的控制器直接去操作 WebSocket 连接对象那是典型的架构翻车点——FPM 进程和 Swoole 进程根本不在一个内存空间。我一般会这样划分模块承载方原因登录注册、好友管理ThinkPHP6需要事务、ORM、中间件消息收发、在线状态Swoole WebSocket常驻内存、高并发离线消息存储MySQL Redis持久化 快速读取消息推送触发Redis Pub/Sub解耦两个进程定时任务、心跳检测Swoole Timer不依赖外部 cron这个划分不是死规定但边界清晰能省掉大量调试时间。新手最容易犯的错是把所有逻辑塞进 Swoole 的 onMessage 回调里结果一个慢查询就把整个连接池堵死。2.2 用 Swoole 启动 WebSocket 服务的最小命令先确认环境PHP 7.4 以上、Swoole 4.8 以上、Redis 扩展、PDO MySQL 扩展。ThinkPHP6 用 Composer 装Swoole 用 pecl 装。# 安装 ThinkPHP6 composer create-project topthink/think im-server cd im-server # 安装 Swoole假设已装 pecl pecl install swoole # 在 php.ini 中加入 extensionswoole.so # 安装 Redis 扩展 pecl install redis # 在 php.ini 中加入 extensionredis.so # 验证 php -m | grep -E swoole|redis确认扩展加载后在项目根目录建一个swoole_server.php这是 WebSocket 服务的入口?php // swoole_server.php use Swoole\WebSocket\Server; use Swoole\Http\Request; use Swoole\WebSocket\Frame; $server new Server(0.0.0.0, 9502); // 连接建立时触发用于绑定用户ID和fd $server-on(open, function (Server $server, Request $request) { // 实际项目中这里要校验 token从 Redis 取用户信息 echo connection open: {$request-fd}\n; }); // 收到消息时触发核心分发逻辑 $server-on(message, function (Server $server, Frame $frame) { $data json_decode($frame-data, true); // 根据消息类型分发单聊、群聊、心跳、已读回执 switch ($data[type] ?? ) { case ping: $server-push($frame-fd, json_encode([type pong])); break; case single: // 单聊查目标用户fd推送 break; case group: // 群聊查群成员fd列表批量推送 break; } }); // 连接关闭时触发清理在线状态 $server-on(close, function ($server, $fd) { echo connection close: {$fd}\n; }); $server-start();这段代码的逻辑说明open回调里做鉴权和 fd 与 uid 的绑定通常把映射写进 Redis 的 Hashkey 是online:uidvalue 是 fd。message回调是消息总线按 type 字段分发。close回调必须清理 Redis 里的映射否则会出现「用户已离线但系统以为在线」的幽灵状态。参数说明端口 9502 是 Swoole 常用端口可改。0.0.0.0表示监听所有网卡生产环境建议配合防火墙只开必要端口。json_decode的第二个参数 true 表示返回数组方便后续处理。启动命令php swoole_server.php看到connection open日志就说明服务起来了。用浏览器控制台或者 WebSocket 测试工具连ws://127.0.0.1:9502就能验证。2.3 消息时序和离线补偿怎么处理即时通讯最怕的不是消息发不出去而是消息顺序乱了、或者用户离线期间的消息丢了。Swoole 的 push 是异步的多个消息同时推给同一个 fd 时到达顺序不保证。解决办法是在服务端给每条消息分配一个自增序列号客户端按序列号排序。离线补偿的常见做法是用户上线时先拉取 Redis 里缓存的最近 N 条消息再拉 MySQL 里的历史消息。Redis 用 List 结构key 是msg:uid每次推送前先LPUSH用户上线后LRANGE取最近 100 条取完DEL。这样既保证不丢又不会让 Redis 无限膨胀。// 用户上线时拉取离线消息 $redis new Redis(); $redis-connect(127.0.0.1, 6379); $offlineKey msg:offline:{$uid}; $messages $redis-lRange($offlineKey, 0, -1); if (!empty($messages)) { foreach ($messages as $msg) { $server-push($fd, $msg); } $redis-del($offlineKey); // 拉取后删除避免重复 }注意lRange取完后立刻del有风险如果推送过程中连接断了消息就丢了。更稳妥的做法是先标记已读再删除或者用 Redis 的MULTI事务包住。这个细节后面避坑章节还会展开。3. 前端 uni-app 对接一套代码怎么同时喂饱 H5 和小程序3.1 WebSocket 连接在 uni-app 里的正确打开方式uni-app 提供了uni.connectSocket这个跨端 APIH5、小程序、App 都能用。但不同端的表现差异很大尤其是微信小程序对 WebSocket 的限制比 H5 多得多。常见做法是封装一个 Socket 管理类统一处理连接、重连、心跳、消息分发。// utils/socket.js class SocketManager { constructor() { this.socket null; this.isConnected false; this.reconnectTimer null; this.heartbeatTimer null; this.listeners {}; } connect(url, token) { this.socket uni.connectSocket({ url: ${url}?token${token}, success: () console.log(socket connect success), fail: (err) console.error(socket connect fail, err) }); this.socket.onOpen(() { this.isConnected true; this.startHeartbeat(); }); this.socket.onMessage((res) { const data JSON.parse(res.data); // 按消息类型分发给注册的监听器 if (this.listeners[data.type]) { this.listeners[data.type].forEach(fn fn(data)); } }); this.socket.onClose(() { this.isConnected false; this.stopHeartbeat(); this.reconnect(url, token); }); this.socket.onError((err) { console.error(socket error, err); this.socket.close(); }); } startHeartbeat() { this.heartbeatTimer setInterval(() { if (this.isConnected) { this.socket.send({ data: JSON.stringify({ type: ping }) }); } }, 30000); // 30秒一次心跳 } stopHeartbeat() { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer null; } } reconnect(url, token) { if (this.reconnectTimer) return; this.reconnectTimer setTimeout(() { this.reconnectTimer null; this.connect(url, token); }, 5000); // 5秒后重连 } on(type, callback) { if (!this.listeners[type]) this.listeners[type] []; this.listeners[type].push(callback); } send(data) { if (this.isConnected) { this.socket.send({ data: JSON.stringify(data) }); } } } export default new SocketManager();逻辑说明connect方法建立连接并注册四个回调。onOpen里启动心跳onMessage里按 type 分发onClose里触发重连onError里主动关闭触发重连。startHeartbeat每 30 秒发一次 ping服务端回 pong用来检测连接是否还活着。reconnect用 setTimeout 做延迟重连避免频繁重试打爆服务端。参数说明心跳间隔 30 秒是经验值微信小程序建议不低于 20 秒否则容易被系统回收。重连延迟 5 秒也是经验值太短会导致服务端压力大太长用户体验差。uni.connectSocket的 url 参数在 H5 端是ws://或wss://在小程序端必须是wss://且域名要在小程序后台配置。3.2 多端适配的 manifest 配置和条件编译uni-app 的manifest.json是打包配置的核心不同端的差异都在这里。做即时通讯项目有几个配置必须改{ name: im-app, appid: , description: 仿QQ即时通讯, versionName: 1.0.0, versionCode: 100, transformPx: false, app-plus: { usingComponents: true, nvueStyleCompiler: uni-app, compilerVersion: 3, splashscreen: { alwaysShowBeforeRender: true, waiting: true, autoclose: true, delay: 0 }, modules: { Push: {}, VideoPlayer: {} }, distribute: { android: { permissions: [ uses-permission android:name\android.permission.INTERNET\/, uses-permission android:name\android.permission.ACCESS_NETWORK_STATE\/ ] }, ios: {}, sdkConfigs: {} } }, quickapp: {}, mp-weixin: { appid: 你的小程序appid, setting: { urlCheck: false, es6: true, postcss: true, minified: true }, usingComponents: true, permission: { scope.userLocation: { desc: 用于发送位置消息 } }, requiredPrivateInfos: [getLocation] }, h5: { title: 仿QQ即时通讯, router: { mode: hash, base: ./ }, devServer: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } } }逻辑说明app-plus里的modules按需引入推送和视频播放模块不用的模块不要加否则包体积会膨胀。mp-weixin里的urlCheck: false是开发阶段跳过域名校验上线前必须改回 true 并在小程序后台配置合法域名。h5里的proxy解决开发阶段跨域生产环境用 Nginx 反代。参数说明transformPx: false表示不使用 px 转 rpx即时通讯界面通常用 rpx 做自适应这个选项按项目习惯设。router.mode用 hash 是为了兼容静态部署如果服务器支持 history 模式可以改成 history。条件编译是 uni-app 处理多端差异的利器比如 WebSocket 地址在不同端不一样// #ifdef H5 const WS_URL ws://127.0.0.1:9502; // #endif // #ifdef MP-WEIXIN const WS_URL wss://your-domain.com/ws; // #endif // #ifdef APP-PLUS const WS_URL ws://your-server-ip:9502; // #endif注意微信小程序的 WebSocket 必须用 wss且域名要在小程序后台的「开发管理-开发设置-服务器域名」里配置。H5 端如果页面是 httpsWebSocket 也必须用 wss否则浏览器会拦截。App 端相对宽松但生产环境也建议上 wss。3.3 消息列表和聊天窗口的渲染性能仿 QQ 的聊天窗口要处理大量消息气泡如果直接用 v-for 渲染几百条消息低端安卓机上会卡到怀疑人生。常见优化手段有三个虚拟列表、消息分页、图片懒加载。虚拟列表在 uni-app 里可以用scroll-view配合scroll-into-view实现只渲染可视区域内的消息。消息分页是每次只加载最近 20 条上拉加载更多。图片懒加载用image组件的lazy-load属性。template scroll-view scroll-y :scroll-into-viewlastMsgId scrolltoupperloadMore classmsg-list view v-formsg in visibleMessages :keymsg.id :idmsg- msg.id view :class[bubble, msg.from myUid ? self : other] image :srcmsg.avatar lazy-load modeaspectFill classavatar / text classcontent{{ msg.content }}/text /view /view /scroll-view /template script export default { data() { return { messages: [], visibleMessages: [], lastMsgId: , pageSize: 20 }; }, methods: { loadMore() { // 从历史消息里再取一页 const start this.visibleMessages.length; const more this.messages.slice(start, start this.pageSize); this.visibleMessages [...more, ...this.visibleMessages]; }, scrollToBottom() { if (this.messages.length) { this.lastMsgId msg- this.messages[this.messages.length - 1].id; } } } }; /script逻辑说明scroll-into-view绑定最后一条消息的 id新消息到达时自动滚到底部。scrolltoupper触发加载更多历史消息。visibleMessages只保留当前渲染的消息避免一次性渲染全部。参数说明pageSize设为 20 是平衡加载速度和内存占用的经验值。lazy-load在 H5 端支持有限小程序和 App 端效果更好。modeaspectFill保证头像不变形。4. 避坑与排查那些让项目延期两周的细节4.1 心跳包发了但服务端没收到现象客户端日志显示每 30 秒发一次 ping但服务端onMessage回调里看不到 ping 消息连接过一段时间就被断开。原因微信小程序在后台运行时WebSocket 会被系统挂起setInterval也会被暂停。H5 端如果页面切到后台浏览器会降低定时器频率。另外Swoole 的onMessage回调里如果做了耗时操作消息会排队看起来像没收到。解决心跳间隔不要低于 20 秒微信小程序建议 25 到 30 秒。服务端在onMessage里对 ping 做最快路径处理不要查库。同时服务端要设置heartbeat_check_interval和heartbeat_idle_time主动踢掉死连接。$server-set([ heartbeat_check_interval 60, // 每60秒检查一次 heartbeat_idle_time 120, // 120秒没数据就断开 ]);4.2 离线消息重复推送现象用户上线后收到两条一样的离线消息或者同一条消息在多个设备上重复出现。原因lRange取消息后del之前如果推送过程中连接断了消息没删掉下次上线又拉一遍。或者多端登录时每个端都去拉离线消息导致重复。解决用 Redis 的MULTI事务包住lRange和del或者用LPOP逐条取逐条删。多端场景下离线消息只推给主设备其他设备通过同步接口拉取。$redis-multi(); $messages $redis-lRange($offlineKey, 0, -1); $redis-del($offlineKey); $redis-exec();4.3 群聊消息扩散导致服务端卡死现象一个 500 人群发消息服务端 CPU 瞬间飙到 100%其他用户的消息延迟好几秒。原因群聊消息要推给所有在线成员如果在一个循环里同步push每个 push 都有网络开销500 个就是 500 次。而且如果某个成员的连接已经断了push 会阻塞。解决用 Swoole 的Task异步任务处理群聊扩散主进程只负责接收消息扩散逻辑丢给 Task Worker。同时用push的返回值判断连接是否有效无效的从在线列表里移除。$server-on(message, function ($server, $frame) { $data json_decode($frame-data, true); if ($data[type] group) { $server-task($data); // 丢给 Task Worker } }); $server-on(task, function ($server, $taskId, $workerId, $data) { $members getGroupMembers($data[group_id]); foreach ($members as $uid) { $fd getFdByUid($uid); if ($fd !$server-push($fd, json_encode($data))) { removeOnline($uid); // push失败说明连接已断 } } return done; });4.4 uni-app 打包 H5 后 WebSocket 连不上现象开发环境 WebSocket 正常打包部署到服务器后连不上控制台报WebSocket connection failed。原因H5 打包后是静态文件WebSocket 地址写的是ws://127.0.0.1:9502部署到服务器后这个地址指向的是用户本机不是服务器。另外如果页面是 httpsWebSocket 必须用 wss。解决用环境变量区分开发和生产生产环境用wss://your-domain.com/ws并在 Nginx 里配置 WebSocket 反代。location /ws { proxy_pass http://127.0.0.1:9502; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; }4.5 消息时间显示错乱现象聊天窗口里消息的时间顺序和实际发送顺序不一致尤其是快速连续发送时。原因客户端用本地时间排序不同设备时间不同步。或者服务端没有给消息分配全局序列号客户端按到达顺序渲染。解决服务端给每条消息分配自增序列号客户端按序列号排序。时间显示用服务端时间不要用客户端本地时间。// 客户端按 seq 排序 messages.sort((a, b) a.seq - b.seq);5. 进阶技巧用 Redis 发布订阅打通 ThinkPHP6 和 Swoole5.1 为什么需要发布订阅ThinkPHP6 处理完业务逻辑后比如「用户 A 给用户 B 发了一条消息」需要通知 Swoole 去推送。但 ThinkPHP6 是 FPM 进程Swoole 是常驻进程两者不能直接调用。常见做法是用 Redis 的发布订阅ThinkPHP6 往频道里publishSwoole 订阅这个频道收到消息后推送给对应的 fd。这个模式的好处是解耦ThinkPHP6 不需要知道 Swoole 的地址和端口Swoole 也不需要知道业务逻辑。坏处是 Redis 如果挂了消息会丢。生产环境建议用更可靠的消息队列比如 RabbitMQ 或 Kafka但 Redis 对于中小项目够用。5.2 在 Swoole 里订阅 Redis 频道Swoole 的onWorkerStart回调里可以启动一个 Redis 订阅协程注意要用Swoole\Coroutine\Redis或者独立的进程不要阻塞主进程。$server-on(workerStart, function ($server, $workerId) { // 只在第一个 worker 里订阅避免重复消费 if ($workerId 0) { go(function () use ($server) { $redis new Swoole\Coroutine\Redis(); $redis-connect(127.0.0.1, 6379); $redis-subscribe([im_channel], function ($redis, $channel, $message) use ($server) { $data json_decode($message, true); $fd getFdByUid($data[to_uid]); if ($fd) { $server-push($fd, json_encode($data)); } }); }); } });逻辑说明go创建一个协程subscribe是阻塞的所以必须放在协程里。$workerId 0保证只有一个 worker 订阅避免消息被重复消费。getFdByUid从 Redis 里查 fd 映射。参数说明im_channel是频道名可以按业务拆成多个频道比如im_single、im_group。Swoole\Coroutine\Redis需要 Swoole 4.0 以上且编译时开启了协程 Redis 支持。5.3 ThinkPHP6 侧发布消息在 ThinkPHP6 的控制器或服务层里处理完业务逻辑后往 Redis 频道发布消息// app/service/MessageService.php namespace app\service; use think\facade\Cache; class MessageService { public function send($fromUid, $toUid, $content) { // 1. 落库 $msgId Db::name(messages)-insertGetId([ from_uid $fromUid, to_uid $toUid, content $content, create_time time(), ]); // 2. 发布到 Redis 频道 $redis Cache::store(redis)-handler(); $redis-publish(im_channel, json_encode([ type single, msg_id $msgId, from_uid $fromUid, to_uid $toUid, content $content, seq $msgId, // 用消息ID做序列号 ])); return $msgId; } }逻辑说明先落库拿到消息 ID再用消息 ID 做序列号保证全局唯一且递增。然后发布到 Redis 频道Swoole 订阅后推送给目标用户。如果目标用户不在线Swoole 会把消息存到离线队列。参数说明Cache::store(redis)-handler()拿到的是原生 Redis 对象才能用publish。seq用消息 ID 是最简单的方案如果分库分表了就需要单独的序列号生成器。5.4 验证整条链路是否打通启动 Swoole 服务再开一个终端用 Redis 客户端手动发布一条消息redis-cli publish im_channel {type:single,to_uid:1,content:test}如果 Swoole 日志里看到推送记录说明订阅生效。再用两个浏览器标签分别登录两个账号互相发消息看是否能实时收到。最后测离线场景关掉一个标签另一个标签发消息再打开关掉的标签看是否能收到离线消息。我自己的习惯是每次改完消息链路先跑一遍「在线互发 → 离线补偿 → 多端同步」三个场景确认无误再继续开发其他功能。这个习惯帮我省掉了无数次上线后才发现消息丢失的后悔药。希望帮到你。本文还有配套的精品资源点击获取