简介:最新仿WX即时聊天源码,是一套可用于学习即时通讯技术的完整前后端项目。代码涵盖单聊、群聊、注册、添加好友、群管理、公告、禁言、消息免打扰、置顶联系人、新消息声音提醒与浏览器通知等常见IM功能,支持发送表情、图片、语音、视频和文件消息,并已打通一对一音视频通话、文件图片在线预览,以及H5/APP打包和简易后台管理,企业模式与社区模式均有所体现。压缩包共326个文件,以107个PHP接口脚本、18个JS逻辑层、HTML/CSS页面为主,另含SQL初始化脚本、TTF/WOFF字体、PNG图片素材等,整体约10.76MB,目录结构便于按模块查阅。目前已有363人学习/下载,适合有一定Web基础、想深入仿微信IM开发或需要部署演练的开发者参考。通过这份源码可以研究已读未读状态、在线状态、群禁言等细节实现,以及音视频通话在Web端与移动端的落地方式。
1. 这套仿WX即时聊天源码,先搞懂它到底能干什么
拿到这份“仿WX即时聊天源码”时,我的第一反应是“又是个套壳 H5 吧”,拆完发现判断错了一半。它不是纯网页包壳,而是前后端分工明确的即时通讯工程:Web 端是仿微信的会话列表、聊天气泡、通讯录和发现页,后端带 WebSocket 长连接、离线消息拉取,以及基于 WebRTC 的视频语音通话。它能解决三件事:你想快速搭一个类似微信聊天界面的应用但不想从零写 UI;你需要一套真能打通音视频的 IM 参考代码,而不只是登录注册页;你正在做毕业设计或课程设计,需要一个能当场演示的完整项目。适合后端想补前端、前端想补 WebRTC 的从业者,也适合急着交项目的学生。拿回去先照第 3 章部署,跑通再读源码,效率最高。
2. 技术架构与选型:WebSocket 长连接 + WebRTC 媒体链路
2.1 为什么是 WebSocket 而不是 HTTP 轮询
聊天消息的本质是“服务端主动找客户端”,HTTP 轮询能做到但代价很大。客户端每隔几秒请求一次,延迟最低也要一个轮询周期,服务器还要扛住大量无效请求。这套源码选了 WebSocket,一次握手之后双向推送,消息延迟压到百毫秒级,这是 IM 场景的基础选型。
WebSocket 连接不是建立就完事,还要解决“看起来连着、实际早断了”的问题。运营商、浏览器、nginx 都会回收空闲连接,所以必须有心跳。我拆这套源码时看到前端维护了一个定时 ping 的循环,服务端收到 ping 回 pong,连续几次没回应就主动重连。心跳间隔通常设 30 秒,太短会打爆服务器,太长感知不到断线。
const HEARTBEAT_INTERVAL = 30000; const RECONNECT_MAX = 5; let ws = null; let heartbeatTimer = null; let reconnectCount = 0; function connect(sessionToken) { ws = new WebSocket(`wss://your-domain/ws?token=${sessionToken}`); ws.onopen = () => { startHeartbeat(); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'PONG') return; handleMessage(msg); }; ws.onclose = () => { stopHeartbeat(); if (reconnectCount < RECONNECT_MAX) { setTimeout(() => connect(sessionToken), 2000 * reconnectCount); reconnectCount++; } }; } function startHeartbeat() { heartbeatTimer = setInterval(() => { ws.send(JSON.stringify({ type: 'PING' })); }, HEARTBEAT_INTERVAL); }这段代码有两点值得注意。token 放在 query 里而不是请求头,因为浏览器 WebSocket API 不支持自定义 header,这是常见做法;重连用 2 秒乘重试次数的退避策略,避免几十个客户端同时断网恢复后一瞬间打爆服务端。实际使用时,建议在后端也维护一个“最后心跳时间”,超时 90 秒没有心跳就主动 close,防止僵尸连接占满文件描述符。
2.2 WebRTC 链路:STUN、TURN 与编解码的选择
文字消息走 WebSocket,音视频为什么还要另一条链路?因为媒体流不适合在可靠传输的 TCP 上跑,延迟高且带宽浪费。这套源码用的是 WebRTC,浏览器之间直接传 UDP 流,信令协商走 WebSocket,媒体数据用 DTLS/SRTP 加密。这样分工之后,服务器只转发信令,不转发媒体,单机承载力大幅提升。
WebRTC 最麻烦的不是编解码,是 NAT 穿透。两个用户都在家里路由器后面,各自拿到的 IP 是内网地址,怎么找到对方?STUN 服务器负责帮客户端发现自己映射到公网的地址,找到就能直连;找不到或双方都是对称型 NAT 时,必须走 TURN 中继转发媒体。这是我在部署这套源码时踩过的最深的一个坑,后面第 5 章会细说。先看默认配置:
| 参数 | 推荐值 | 作用 |
|---|---|---|
| stunServer | stun:stun.l.google.com:19302 | NAT 穿透发现公网地址,不转发媒体 |
| turnServer | turn:your-domain:3478 | 对称 NAT 兜底,需要账号密码 |
| iceTransportPolicy | all | 设成 relay 时强制走中继,一般不用 |
| maxBitrate | 800kbps 视频 / 32kbps 音频 | 防止弱网拥塞 |
| echoCancellation | true | 回声消除,必须开 |
编解码方面,视频默认走 VP8,兼容性最好;Chrome 和 Firefox 都支持 H.264,但在低端设备上 VP8 的软编性能更稳定。音频固定 Opus,48kHz 采样,带宽占用低。如果要改分辨率,在getUserMedia的 constraints 里调 width 和 height,而不是在编码器里硬调,这是 WebRTC 的流量控制机制决定的——编码器会按网络状况自动降码率,你设的分辨率只是上限。
2.3 源码目录结构与三大模块
拿到压缩包后不要急着运行,先看目录结构。这套工程拆成前后端和 SQL 三块,互相独立,可以分开部署:
wx-im/ ├── app-server/ # 后端服务,Spring Boot 工程 │ ├── src/main/java/ │ │ ├── controller/ # HTTP 接口:登录、好友、拉取消息 │ │ ├── websocket/ # WebSocket 信令网关 │ │ └── service/ # 业务逻辑:离线消息、会话管理 │ ├── src/main/resources/ │ │ └── application.yml # 核心配置文件 │ └── pom.xml ├── app-web/ # Web 聊天端,Vue3 + Vite │ ├── src/ │ │ ├── views/ # 会话、通讯录、聊天、通话页 │ │ └── api/ # 封装 HTTP + WebSocket 客户端 │ └── package.json ├── sql/ │ └── init.sql # 建库建表脚本 └── docs/ ├── deploy.md # 部署文档 └── api.md # 接口说明| 目录 | 作用 | 改动频率 |
|---|---|---|
| app-server | 登录鉴权、消息存储、信令转发 | 高 |
| app-web | 仿微信 UI、媒体采集、通话状态机 | 高 |
| sql/init.sql | 12 张核心表:用户、好友、群组、消息、会话、呼叫记录 | 低 |
| docs/ | 部署步骤与接口文档 | 中 |
后端没有用单独的 MQ 或推送服务,消息落地直接用数据库表 + Redis 缓存在线状态。这种设计对单机部署友好,两台机器就能跑完整套 IM,不需要引入 Kafka、Zookeeper 之类的基础设施。代价是水平扩展需要自己改造,后面第 6 章会提。
3. 把源码跑起来:从空环境到双端联调的四步
3.1 环境依赖清单
先确认机器上有这些,缺哪个装哪个。我第一次跑的时候卡在 MySQL 字符集上,来回折腾了两小时,提醒各位先看版本。
| 依赖 | 版本要求 | 用途 |
|---|---|---|
| JDK | 1.8+ | 跑后端 Spring Boot |
| Maven | 3.6+ | 编译后端 |
| Node.js | 16+ | 跑前端 Vite 开发服务器 |
| MySQL | 5.7 或 8.0 | 业务数据落库 |
| Redis | 5.0+ | 在线状态、token 缓存 |
MySQL 要额外注意一个坑:建库时字符集必须设成 utf8mb4,不能只设 utf8,否则用户昵称里带个 emoji 直接插入报错Incorrect string value。后面踩坑章节会展开。
3.2 四步部署:导入数据库到双端联调
第一步是导数据库。在项目根目录执行:
mysql -uroot -p < sql/init.sql执行完可以用show tables;确认。如果报错提示数据库不存在,先手动建库再导:
mysql -uroot -p -e "CREATE DATABASE wx_im DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p wx_im < sql/init.sqlinit.sql 里包含 12 张核心表,用户表、好友关系表、群成员表、单聊消息表、群聊消息表、会话列表表、呼叫记录表等。消息表主键自增,同时建了from_user_id + to_user_id + create_time的联合索引,拉聊天记录时能走索引,数据量上去之后不至于全表扫。
第二步改后端配置。打开app-server/src/main/resources/application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/wx_im?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your-password redis: host: localhost port: 6379 webrtc: stun-server: stun:stun.l.google.com:19302 turn-server: turn:your-domain:3478 turn-username: your-turn-user turn-password: your-turn-password im: ws-path: /ws token-expire-hours: 72这三个配置段各管一件事。datasource是数据库连接,注意 URL 里必须带characterEncoding=utf8和serverTimezone,缺了会在插入中文或时间字段时报错。webrtc是媒体协商要用的穿透服务器参数,没有 TURN 服务器可以先只配 STUN,局域网点对点测试没问题,部署到公网就必须要 TURN。im里的token-expire-hours控制登录态有效时间,开发阶段建议调成 720,免得每半天重登一次。
第三步启动后端。在app-server目录执行:
mvn clean package -DskipTests java -jar target/wx-im-server-*.jar看到日志输出WebSocket server started at ws://0.0.0.0:8080/ws就算启动成功。这一步常见问题是 Maven 下载依赖慢,换阿里云镜像能快不少。
第四步启动前端。在app-web目录执行:
npm install npm run dev浏览器打开http://localhost:5173,用 init.sql 里预置的测试账号登录。如果没有预置账号,直接注册一个,默认用户表没有开启邮箱验证,注册即用。
3.3 配置参数速查与第一次登录验证
跑通之后别急着改代码,先把配置参数的含义记到自己的笔记里。我整理的速查表如下,后面排查问题时对照看:
| 参数 | 默认值 | 说明与建议 |
|---|---|---|
im.ws-path | /ws | WebSocket 连接路径,前端代码里写死了,改后端要同步改前端 |
im.token-expire-hours | 72 | token 过期时间,公网部署建议缩短到 24 |
spring.redis | localhost:6379 | 在线状态存 Redis,Redis 挂了连接会拒 |
webrtc.stun-server | Google 公共 STUN | 只做穿透探测,不传媒体,可放心使用 |
webrtc.turn-server | 需自建 | 没有 TURN 时跨运营商通话大概率失败 |
第一次登录建议做一次完整冒烟:A 账号给 B 账号发一条文字消息,再发起一次语音通话。文字消息能通说明 WebSocket 链路和数据库读写正常,语音能通说明 WebRTC 信令链路正常。两件事都成了,这套源码才算真正跑起来了,可以进入下一步的代码拆解。
4. 视频语音聊天实现链路:信令、SDP 交换与状态机
4.1 一次通话的六个信令节点
看视频通话功能,不要先看媒体代码,先看信令。信令是两个人的协商语言,这套源码里完整的通话过程是六个节点:
| 顺序 | 信令类型 | 发送方 | 内容 |
|---|---|---|---|
| 1 | CALL | 主叫 → 被叫 | 携带 roomId、主叫昵称头像 |
| 2 | RINGING | 被叫 → 主叫 | 通知主叫已在振铃 |
| 3 | OFFER | 主叫 → 被叫 | 携带 SDP offer |
| 4 | ANSWER | 被叫 → 主叫 | 携带 SDP answer |
| 5 | ICE_CANDIDATE | 双向 | 持续交换候选地址 |
| 6 | HANGUP | 任意方 | 挂断并释放资源 |
roomId 由主叫方生成,推荐用uuid或时间戳 + 随机数。CALL 消息到达被叫端后,被叫界面弹出接听弹窗,这时候媒体链路还没建立,不要提前播放铃声以外的音效。
4.2 服务端信令转发代码拆解
后端在这个流程里只做一件事:把信令从 A 转发给 B。不要在里面写业务逻辑,比如“判断对方是否好友”这类校验应该在 WebSocket 连接建立时完成,否则信令延迟会明显增高。拆解后的核心转发逻辑:
public void handleSignal(String toUserId, SignalMessage msg) { WebSocketSession targetSession = sessionManager.getSession(toUserId); if (targetSession == null) { // 目标不在线,推送离线呼叫通知或直接丢弃 pushOfflineCall(toUserId, msg); return; } synchronized (targetSession) { targetSession.sendMessage(new TextMessage(msg.toJson())); } }注意synchronized (targetSession)这行,不是玄学,是血泪经验。多个线程同时向同一个 WebSocket 会话 sendMessage,Netty 底层会抛Cannot send message when WebSocketChannel is not open,因为底层 channel 的写操作不是线程安全的。加锁是为了避免并发写同一个 session 导致连接异常关闭。
信令消息统一走 JSON,不要为了省流量用二进制协议,排查问题时 tcpdump 抓包能直接看到内容,这是开发期最大的便利。等上线稳定后再考虑换 Protobuf,第 6 章会提。
4.3 前端发起与应答:getUserMedia 与 RTCPeerConnection
前端是这套源码里最复杂的部分,因为要管理三个异步状态:媒体流采集、ICE 候选收集、远端流渲染。发起通话的核心代码:
async function startCall(toUserId) { const pc = new RTCPeerConnection({ iceServers: [stunServer, turnServer] }); const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, audio: { echoCancellation: true, noiseSuppression: true } }); stream.getTracks().forEach(track => pc.addTrack(track, stream)); localVideo.srcObject = stream; pc.ontrack = (event) => { remoteVideo.srcObject = event.streams[0]; }; pc.onicecandidate = (event) => { if (event.candidate) { sendSignal({ type: 'ICE_CANDIDATE', candidate: event.candidate }); } }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal({ type: 'OFFER', sdp: pc.localDescription }); }这段代码里有三个最容易翻车的地方。第一,getUserMedia必须在用户点击事件里调用,浏览器禁止自动播放带声音的媒体流,Chrome 会直接抛NotAllowedError。第二,createOffer之后必须先setLocalDescription再发送,发送的是pc.localDescription而不是offer,两者在大多数情况下一样,但 setLocalDescription 可能修改 SDP 内容。第三,onicecandidate回调触发时间不定,必须在信令消息里带上序号或者依赖 WebSocket 的单通道保序特性,这套源码直接复用同一个 WebSocket,天然避免乱序。
被叫方应答时逻辑对称,但多一步:收到 OFFER 后setRemoteDescription,创建 ANSWER 并setLocalDescription,然后回传。ICE 候选的交换是双向的,任何一方收集到新的 candidate 都要发给对方。
4.4 离线消息、已读回执与消息去重
音视频搞定之后,IM 的另一半是可靠消息。这套源码的消息格式是标准 JSON,字段如下:
{ "type": "TEXT", "messageId": "a3f5c1e2-8b0d-4f1a-9c2e-5d8b7a1e3f2a", "fromUserId": 1001, "toUserId": 1002, "content": "在吗", "timestamp": 1710000000000 }messageId由客户端生成 UUID,服务端把它作为唯一索引。为什么要这样设计?因为 WebSocket 在弱网下可能重连重发,如果没有 messageId,服务端无法判断是否重复。我在拆解时确认这套源码的做法是:插入消息前先按message_id查一次,已存在直接返回“已接收”,避免重复落库。这是 IM 系统最核心的幂等设计。
离线消息的处理走 HTTP 拉取而不是 WebSocket 推送。用户登录后调用/api/message/pull?afterSeq=1000,服务端查大于这个 seq 的所有消息,批量返回。seq 是消息表自增主键,单聊群聊共用一张表,所以这个接口天然支持增量拉取。已读回执单独发一条READ_RECEIPT信令,接收方更新会话列表里的已读位置,不需要持久化到 Redis,直接更新内存状态就行,刷新页面后从数据库重新拉。
5. 避坑与排查:视频黑屏、频繁断线、消息重复的四份踩坑记录
5.1 视频通话黑屏无画面:先查 ICE 状态
现象:两端都显示已接通,通话计时在走,但双方画面都是黑的,或者只有本地画面没有远端画面。
原因:ICE 协商失败,媒体流根本没建立。看pc.iceConnectionState的值,停在checking超过十秒就是穿透失败。大多数情况是 STUN 配置了但网络环境是对称 NAT,STUN 发现不了可用地址,需要 TURN 中继。
解决:先把iceTransportPolicy临时设成relay,强制走 TURN,如果通了,就确认是穿透问题。在浏览器 Console 输入pc.getStats(),过滤transport类型,看selectedCandidatePair里是srflx还是relay。这一步能定位是 STUN 不够用还是 TURN 根本没配。
5.2 WebSocket 频繁断线:nginx 代理超时与多实例会话漂移
现象:页面开着不动,过一会儿消息发不出去,刷新后恢复,过一会儿又断。
原因:最常见的两个。后端直接暴露端口且没有断线重连,运营商回收空闲连接;或者前面挂了 nginx,默认proxy_read_timeout60 秒,空闲连接直接被掐断。
解决:先加一层心跳,30 秒一次,让连接始终处于活跃状态。再看 nginx 配置:
location /ws { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_set_header Upgrade和Connection "upgrade"这两行不能少,少了 WebSocket 握手直接失败。多实例部署时还要注意另一个隐藏问题:负载均衡默认轮询,第一次连接落在实例 A,第二次重连落在实例 B,B 没有 A 的 session 状态,表现为“连接成功但消息收不到”。解决方式是 nginx 开ip_hash或者在后端引入 Redis 共享 session。
5.3 消息发两次:messageId 去重与唯一索引
现象:A 给 B 发一条消息,B 看到两条一模一样的,刷新后其中一条消失。
原因:客户端发送超时自动重试,服务端没有按 messageId 去重,同一条消息被插入了两次。刷新后另一条消失,是因为拉取逻辑按 seq 增量拉,重复消息只在当时推送窗口出现。
解决:确认消息表对message_id建了唯一索引:
ALTER TABLE im_message ADD UNIQUE INDEX uk_message_id (message_id);服务端插入前捕获DuplicateKeyException,捕获后直接返回成功,不要给客户端报错。这是幂等设计的标准姿势。客户端侧,每次发送时重新生成 messageId,但重发时必须复用同一个 messageId,否则服务端无法识别为同一条消息。
5.4 打电话半分钟自动掉线:未释放的媒体资源与信令状态失配
现象:通话到第 20 秒左右自己挂断,没有 HANGUP 信令,本地显示“通话结束”。
原因:大概率是通话状态机没对齐。一端因网络波动被动断开,另一端不知道,媒体流还在跑,但信令层已经判定超时。这套源码默认在收到 ICE 连接状态变为disconnected后 15 秒内没有恢复就主动挂断,这是保护机制,但 15 秒太短,跨网段弱网环境经常误判。
解决:把 ICE 断线检测的超时从 15 秒调到 30 秒,并增加 ICE 重启动逻辑:
pc.oniceconnectionstatechange = () => { if (pc.iceConnectionState === 'disconnected') { setTimeout(() => { if (pc.iceConnectionState === 'disconnected') { pc.restartIce(); } }, 3000); } };restartIce()会重新触发 ICE 收集流程,很多时候能让连接在 NAT 映射过期前恢复。另外提醒一点,通话结束时记得关闭所有 track,不然麦克风和摄像头指示灯会一直亮着,这在演示现场很尴尬。
6. 复现之后的验证方法与进阶扩展:把 demo 变成可交付的项目
先做一轮验证,确认这套源码不是“能跑”而是“能用”。我每次部署完,会开两台设备,一台电脑一台手机,执行四条命令式的检查:第一,pc.getStats()里RTT稳定在 100ms 以下,packetLoss为 0,说明链路可靠;第二,用 Chrome DevTools 的 Network 面板模拟弱网,把下行带宽限制到 500kbps,文字消息必须秒达,视频会自动降清晰度而不是卡死;第三,杀掉后端进程再拉起,看前端能否在两秒内自动重连并补齐断线期间的消息;第四条不是命令,是操作:让 A 给 B 连发 50 条消息,逐条核对顺序与内容,全部一致才能算消息通道及格。
进阶改造方向有三个。最容易做的是给信令通道加一层保护,把 WebSocket 地址从 ws 换成 wss,在 nginx 层做 TLS 终止,代码几乎不用动,但安全性提升一个量级。其次是给消息体加字段级加密,推荐对content做 AES-GCM 加密,密钥通过 WebSocket 首次握手时交换,这样即使数据库泄露,明文消息也不会直接暴露。最后是把信令协议从 JSON 换成 Protobuf,字段编码后消息体积能缩小到原来的五分之一,网络差的环境下感知明显,但这需要前后端同时改,成本高,更适合作为上线后的优化项。
屏幕共享是 ROI 最高的功能扩展。在startCall里加一个分支,媒体源从getUserMedia换成getDisplayMedia,其余信令逻辑完全复用,一个下午就能做完。注意切换时要把原来的摄像头 track 停掉,否则画面会同时出现两个视频源。
这套源码的价值在于把 WebSocket 长连接、离线消息、WebRTC 信令协商这几条链路完整串通了,踩过的坑我都记在第 5 章。从那以后,我每次部署完 IM 项目,都强制走一遍“文字 → 图片 → 语音通话 → 视频通话 → 挂断”五步回归,任何一步卡住都绝不进入下一阶段开发。希望帮到你,也欢迎你把复现过程中的新坑带回来交流。
本文还有配套的精品资源,点击获取