简介:基于WebSocket实现浏览器端文本、视频与语音即时通讯的完整项目资料,面向具备一定Web前后端基础的开发者,可用于课程设计、毕业设计或工程实训参考。包内包含111个文件,涵盖Java后端服务、HTML/JS前端页面、CSS样式及Docker部署配置等,代码结构清晰,便于按模块理解实时通信的实现思路。资源包仅959KB,轻量易用,可直接导入开发环境运行调试,并通过启动脚本快速启动服务。文件中提供麦克风、摄像头调用与文本收发测试页面,可帮助开发者验证音视频采集、信令交互及WebSocket消息传输效果。目前已有56人学习下载,适合希望快速掌握WebSocket实际应用、并参考前后端联调细节的开发者借鉴。

1. 为什么浏览器端实时通讯绕不开 WebSocket

在做一个网页版微信风格的即时通讯项目时,最容易被忽略的不是发消息,而是连接本身的生死。基于WebSocket实现浏览器端文本、视频、语音的即时通讯,核心难点在于全双工通道的管理和媒体协商:文本消息要广播,视频与语音要依赖WebRTC,而WebSocket恰好是两者之间唯一的信令管道。很多课程设计Demo能跑通聊天,却经不住断网重连和多人房间的考验。下面从连接管理讲起,再看消息协议、媒体协商,最后把Dockerfile和启动脚本里的坑也填上。适合正在做课程设计、毕设,或想在内部系统加一个实时通讯模块的开发者,特别是那些希望理解握手、心跳、SDP交换等底层参数的人。

2. WebSocket 连接管理与消息协议:从握手到广播

2.1 协议层为什么比 HTTP 轮询强

浏览器端做即时通讯,第一反应往往是HTTP轮询。轮询的代价是每次请求都要携带完整Header,服务端要解析、鉴权、处理重复连接,一个100人在线的聊天室如果2秒轮询一次,每秒就有100个请求,其中可能只有2条是有效消息。WebSocket用一个HTTP Upgrade请求把双向通道建立起来,之后每个数据帧头部只有2到14字节。对于文本消息,几百字节的Payload加2字节帧头,比HTTP省了一个数量级。更重要的是,浏览器原生提供 WebSocket 对象,不需要额外引入SDK。

但这个通道不是免死金牌。WebSocket服务端要自己处理心跳、死连接、未认证连接和消息边界。用Node.js的 ws 库时, connection 事件后对象就已经可以收发,框架不提供房间和会话概念,所有东西都要自己定义。这也是下面从协议层开始搭的原因。 ws 库轻量、二进制帧处理简单,能和Express/Node HTTP server复用同一个端口;如果换成Spring Boot,常见做法是 @ServerEndpoint 加 SpringConfigurator ,思路一致,只是把连接生命周期交给容器管理。

2.2 服务端广播与连接生命周期管理

项目里 server.js 的核心结构如下,我用 ws 加Express托管 public 目录下的 chat.html 、 microphoneTest2.html 和 cameraTest.html 。

const http = require('http');
const express = require('express');
const { WebSocketServer } = require('ws');

const app = express();
app.use(express.static('public'));

const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });

// 用Map保存在线连接,key是会话id,value是ws实例
const clients = new Map();

wss.on('connection', (ws, req) => {
  const userId = new URL(req.url, 'http://localhost').searchParams.get('token')
    || `guest-${Date.now()}`;
  clients.set(userId, ws);
  ws.isAlive = true;

  ws.on('pong', () => { ws.isAlive = true; });
  ws.send(JSON.stringify({ type: 'welcome', userId }));

  ws.on('message', (buf) => {
    let msg;
    try {
      msg = JSON.parse(buf.toString());
    } catch {
      ws.send(JSON.stringify({ type: 'error', code: 400, message: 'body must be json' }));
      return;
    }
    if (msg.type === 'chat') {
      const text = String(msg.text || '').slice(0, 500);
      clients.forEach((client) => {
        if (client.readyState === 1) {
          client.send(JSON.stringify({ type: 'chat', from: userId, text, time: Date.now() }));
        }
      });
    }
  });

  ws.on('close', () => clients.delete(userId));
});

setInterval(() => {
  clients.forEach((ws, id) => {
    if (!ws.isAlive) {
      ws.terminate();
      clients.delete(id);
      return;
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

server.listen(3000, () => console.log('listening on 3000'));

这里 readyState === 1 表示连接处于OPEN状态,可以直接 send 。 ws.ping() 是协议层控制帧,浏览器会自动回应 pong ,30秒一次的探测能及时清掉“假死”连接。 token 从URL参数读取只是为了演示,生产环境应该放在请求头或首帧消息里,避免日志泄漏。所有业务消息统一走JSON,解析失败时返回 400 而不是直接断开,这样前端可以根据错误码提示用户,而不是眼睁睁看着连接掉线。

2.3 前端连接状态机与重连策略

浏览器端连接管理要显式处理“主动关闭”和“异常断开”。下面是一个带指数退避重连的封装:

let ws = null;
let attempt = 0;

function connect() {
  ws = new WebSocket(`/ws?token=${encodeURIComponent(userId)}`);

  ws.addEventListener('open', () => {
    attempt = 0;
    ws.send(JSON.stringify({ type: 'hello', client: 'web' }));
  });

  ws.addEventListener('message', (event) => {
    const msg = JSON.parse(event.data);
    dispatch(msg);
  });

  ws.addEventListener('close', (event) => {
    // 1000 是主动关闭,1006 说明网络中断或对端终止
    if (event.code === 1000 || event.code === 4000) return;
    const timeout = Math.min(500 * 2 ** attempt, 10000);
    attempt += 1;
    setTimeout(connect, timeout);
  });
}

connect();

Math.min(500 * 2 ** attempt, 10000) 实现指数退避,第一次1秒、第二次2秒,最多10秒。如果用固定间隔,服务端重启时会有一堆客户端同时重连造成“惊群”。 close 事件里不能只判断 code === 1000 ,因为 1006 在浏览器端是“连接异常关闭且没有关闭帧”,常见原因是NAT超时、服务端 terminate() 或代理回收空闲连接,这种情况必须重连。

关闭码 含义 重连策略
1000 正常关闭 不重连
1001 页面/服务端离开 按业务决定
1006 非正常关闭(无关闭帧) 指数退避重连
4000-4999 自定义业务关闭 按业务码处理

3. 文本聊天实现:JSON 协议、心跳与消息去抖

3.1 定义统一消息协议

消息结构要能同时承载文本、信令和状态,我用一个 type 加一个 data 字段:

{
  "type": "chat",
  "to": "room:lobby",
  "data": {
    "text": "hello"
  }
}

type 用可读的字符串而不是数字,方便在浏览器控制台和Nginx日志里直接排查问题。 to 字段支持 room:xxx 和 user:xxx 两种前缀,这样同一个后端既能群聊也能私聊。时间戳统一用 Date.now() 返回的毫秒,不要用秒,因为前端 new Date() 默认接收毫秒,传秒会出现1970年的诡异时间。

type 方向 用途
hello C→S 连接后鉴权
chat C↔S 文本消息
typing C→S 正在输入事件
ping/pong C↔S 业务心跳
offer / answer / candidate C↔S WebRTC信令

注意业务心跳和2.2里的协议层 ping 不是一回事。协议层 ping 是为了保活TCP连接,业务心跳是为了上报“用户还在这条会话里”,前端可以每30秒发一个 ping ,服务端回 pong 。如果只靠ws协议层心跳,服务端无法区分用户是切后台还是真离线。

3.2 服务端路由与转发

在2.2的Map基础上扩展一个路由函数:

function sendTo(target, payload) {
  const [type, key] = target.split(':');
  if (type === 'room') {
    clients.forEach((client) => {
      if (client.readyState === 1) client.send(JSON.stringify(payload));
    });
    return;
  }
  const ws = clients.get(key);
  if (ws && ws.readyState === 1) {
    ws.send(JSON.stringify(payload));
  } else {
    // 目标离线,可在这里写入Redis离线消息
    console.warn(`offline: ${key}`);
  }
}

ws.on('message', (buf) => {
  const msg = JSON.parse(buf.toString());
  if (msg.type === 'chat') {
    const data = msg.data || {};
    if (typeof data.text !== 'string' || data.text.length === 0) return;
    sendTo(msg.to, { type: 'chat', from: userId, data, time: Date.now() });
  }
});

用 split(':') 而不是正则,是因为这里只需要切割一次,正则反而增加阅读成本。 room: 前缀直接遍历所有连接广播,适合聊天室场景; user: 前缀做精确投递,适合私聊和WebRTC信令。单台服务器几千连接直接遍历Map没有性能问题,真正到十万并发再考虑Redis Pub/Sub,到时候把 clients 换成订阅关系,业务层的 sendTo 函数不用改。

3.3 前端低延迟渲染与 DTO 校验

前端拿到消息后不要无脑 appendChild ,先做轻量校验再渲染:

const list = document.getElementById('messages');

function appendMessage(msg) {
  const el = document.createElement('div');
  el.className = msg.from === currentUser ? 'message mine' : 'message other';
  el.textContent = `${msg.from}: ${msg.data.text}`;
  list.appendChild(el);
}

let lastPaint = 0;
function enqueueMessage(msg) {
  const now = Date.now();
  if (msg.from === currentUser && now - lastPaint < 16) {
    const last = list.lastElementChild;
    last.textContent += msg.data.text;
    return;
  }
  lastPaint = now;
  appendMessage(msg);
}

这里 16ms 对应60fps的渲染周期。普通文本聊天每秒几十条消息其实无所谓,但配合 typing 事件连续触发时,把相似消息合并到同一帧能明显减少DOM重排。 lastElementChild 只引用最后一个节点,避免每次从 querySelector 重新查找。Vue 3项目里“增加 websocket”的常见做法是在 onMounted 里建立连接、 onUnmounted 里关闭,然后用 reactive 数组和 watch 触发渲染,和这里的逻辑一样,只是框架替代了手动DOM操作。

4. 视频与语音通话:WebRTC 信令 + 媒体协商

4.1 为什么媒体流不走 WebSocket

反直觉的地方在于:WebSocket明明是全双工,为什么不能直接推视频帧?因为WebSocket基于TCP,TCP保证顺序和可靠,一旦丢包就要重传,视频画面会卡在等待重传的帧上。WebRTC走UDP,用SRTP加密,通过RTCP反馈丢包情况,丢包时优先恢复音频而不是重传整帧,对实时通话的容忍度完全不同。因此这个项目里WebSocket只做信令通道,负责交换SDP和ICE候选;真正的媒体流通过RTCPeerConnection点对点传输。看到 microphoneTest2.html 和 cameraTest.html ,就知道它们是媒体采集调试页,不是信令页面。

4.2 getUserMedia 与设备遍历

摄像头和麦克风要单独测试,我一般用下面这个方法:

async function startLocalStream(kind) {
  const constraints = {
    video: kind === 'video' ? {
      width: { ideal: 1280 },
      height: { ideal: 720 },
      facingMode: 'user'
    } : false,
    audio: kind !== 'video' ? {
      echoCancellation: true,
      noiseSuppression: true,
      autoGainControl: true
    } : false
  };
  return await navigator.mediaDevices.getUserMedia(constraints);
}

echoCancellation 回音消除必须开,否则扬声器的声音会被麦克风再次采集,形成刺耳回声; noiseSuppression 压制键盘敲击声; autoGainControl 让音量自动调整。 facingMode: 'user' 指定前置摄像头,需要后置时改成 environment 。还要注意 getUserMedia 必须在用户手势里调用,比如点击“开始通话”按钮,否则浏览器直接拒绝。在“电脑端模仿微信浏览器”场景下,修改UA不会让权限判断变宽松,部分浏览器还把 navigator.mediaDevices 隐藏到 navigator.getUserMedia 上,需要做兼容降级。

4.3 RTCPeerConnection 与 SDP 交换

采集到本地流之后,创建 RTCPeerConnection 并添加轨道:

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' }
  ]
});

stream.getTracks().forEach((track) => pc.addTrack(track, stream));

pc.onicecandidate = (event) => {
  if (event.candidate) {
    ws.send(JSON.stringify({
      type: 'candidate',
      to: remoteUserId,
      data: event.candidate.toJSON()
    }));
  }
};

pc.onnegotiationneeded = async () => {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  ws.send(JSON.stringify({
    type: 'offer',
    to: remoteUserId,
    data: pc.localDescription.toJSON()
  }));
};

远端收到 offer 后,要回一个 answer :

async function onRemoteOffer(from, data) {
  await pc.setRemoteDescription(new RTCSessionDescription(data));
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  ws.send(JSON.stringify({
    type: 'answer',
    to: from,
    data: pc.localDescription.toJSON()
  }));
}

onnegotiationneeded 在添加轨道后自动触发,不需要手动调用 createOffer 。 event.candidate 为 null 表示ICE收集完成,这个 null 事件不需要发送给对端。要注意 toJSON() 而不是直接序列化整个对象,因为SDP和ICE对象里有浏览器内部字段,直接发送会造成对端 setRemoteDescription 解析失败。信令消息体必须带 to ,否则多人房间里所有人都去处理同一份offer,会把peer connection状态搅乱。

4.4 房间模型与参会者管理

消息 type 发送方 接收方 载荷
offer 发起方 房间内目标用户 SDP
answer 应答方 offer来源 SDP
candidate 任意一方 对方 ICE候选
bye 任意一方 对端 空

服务端对信令只做转发,不解析SDP内容:

ws.on('message', (buf) => {
  const msg = JSON.parse(buf.toString());
  if (['offer', 'answer', 'candidate', 'bye'].includes(msg.type)) {
    const target = msg.to;
    if (target && clients.has(target)) {
      clients.get(target).send(buf.toString());
    } else {
      ws.send(JSON.stringify({ type: 'error', code: 404, message: 'target offline' }));
    }
  }
});

这种“对称转发”模型省掉了服务端的媒体处理,一个Node进程可以同时支持几百路信令。多对多房间要在参与者加入时广播成员列表,并让新成员向其他成员逐个发送 offer ;超过4人视频建议改用SFU架构如mediasoup,但本项目是一对一或小房间演示,P2P足够。注意转发时直接发 buf.toString() 而不是重新 JSON.stringify(msg) ,能保持原始字段顺序,减少不必要的对象序列化开销。

5. 部署与排错:Dockerfile、批量启动和 1006 断连

5.1 容器化部署

项目根目录的 Dockerfile 可以这样写:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

启动服务.bat 里最核心的两行就是 npm install 和 node server.js ,也可以封装成:

docker build -t ws-im .
docker run -p 3000:3000 ws-im

选 node:20-alpine 而不是 node:latest ,是因为alpine镜像体积小且自带TLS CA,能保证前后端构建环境一致。 npm ci --omit=dev 按 package-lock.json 锁版本安装,避免他人克隆项目后因为依赖版本差异跑不起来。 express.static('public') 会把 microphoneTest2.html 、 cameraTest.html 一起托管,调试时直接访问 http://localhost:3000/microphoneTest2.html 就能测设备。

5.2 常见坑:1006、媒体权限与移动端置顶

症状 原因 处理
浏览器报 [websocket] onclose, code: 1006 心跳未回复,服务端终止 缩短ping间隔,检查代理超时
getUserMedia 返回 NotAllowedError 非HTTPS或非localhost 本地用localhost,公网用HTTPS/WSS
移动端视频自动置顶 百度浏览器/微信内核默认全屏播视频 video标签加 playsinline webkit-playsinline
Chrome 109 出现 wss 握手失败 自签名证书不受信 手动导入证书并重启浏览器

1006 不是业务错误,而是连接被强制关闭且没有关闭帧。常见于Nginx按秒断开空闲连接,或者服务端心跳间隔大于连接清理周期。解决方式是WebSocket层每25秒发一次 ping ,并把Nginx的 proxy_read_timeout 调到60秒以上。用JMeter的WebSocket Sampler压测时如果看到 stream disconnected before completion ,多半就是服务端并发连接触顶或者心跳没有响应,先查这两个点。

5.3 用一条命令验证连接闭环

不需要开浏览器,用Node脚本直接验证服务端广播逻辑:

const WebSocket = require('ws');
const ws = new WebSocket('ws://localhost:3000/ws?token=test');

ws.on('open', () => {
  ws.send(JSON.stringify({ type: 'chat', to: 'room:lobby', data: { text: 'hello' } }));
});
ws.on('message', (buf) => {
  const msg = JSON.parse(buf);
  console.log('received:', msg.type, msg.from);
  process.exit(0);
});

如果收到 welcome 但没有 chat ,说明服务端消息路由有问题;如果两个都没有,先检查 path: '/ws' 是否和访问路径一致。 microphoneTest2.html 和 cameraTest.html 这两个调试页在生产环境也建议保留,排查“没有声音/没有图像”时,先单独测试采集设备,再走WebRTC,能把问题范围缩小一半。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

邀请您加入社区

更多推荐