简介:基于JavaScript WebRTC实现跨平台音视频通话的完整项目源码与说明文档打包为zip,面向计算机、电子信息等专业课程设计、期末大作业及毕业设计参考场景,适合有一定JavaScript基础并希望深入理解WebRTC信令交互、音视频传输与房间管理机制的学习者。资源共32个文件,核心为14个js脚本、7个html页面与4个css样式,分别承担服务端信令控制、前端多人交互界面与样式布局,同时附有SSL证书、package.json配置和中文README项目说明,压缩包整体仅656KB,目录结构清晰便于直接导入运行。目前已有192人学习下载,项目实际覆盖1对1视频、多人视频、视频直播、视频会议、房间管理、权限管理等典型功能,从启动服务到页面操作均有源码和说明支撑。通过对照源码与项目说明,可系统掌握WebRTC的SDP交换、ICE候选协商、信令服务器搭建及权限校验等关键链路,并能基于现有模块快速扩展或改造,适合作为实战入门与二次开发的重要参考资料。

1. 基于 JavaScript 的 WebRTC 项目,到底在解决什么问题

很多人在本地跑通 WebRTC 演示时,都会产生一个错觉:呼叫、接听、视频画面出来,好像音视频通话就这么简单。真正把浏览器从 2 个变成 5 个,或者把一个终端换成 iOS Safari,新问题立刻排队:画面卡顿、声音断续、ICE 候选协商不上。这个标题里的源码+项目说明,本质上是先替你解决了两件事:信令服务器怎么组织,多人场景下 RTCPeerConnection 怎么管理。

本文按实际做这类项目的顺序,把 1 对 1 视频和多人视频分开拆解,覆盖 JavaScript 端的 getUserMedia、RTCPeerConnection 参数、Node.js 信令以及 STUN/TURN 部署,并给出能直接抄回去的代码和命令。准备做 Web 端或混合 App 音视频功能的前端、Node.js 工程师,都可以照着这条路径落地。

2. 先把信令服务器立住:Node.js WebSocket + RTCPeerConnection 协商

WebRTC 的媒体流走 P2P,但 P2P 无法凭空建立。双方互相不知道对方的公网地址,也不清楚对方的音视频编码能力,必须先把 SDP offer/answer 和 ICE candidate 交换一轮。这些元数据不承载媒体内容,但要按严格时序传递,所以信令服务器是整个项目的第一个地基。

2.1 为什么信令不能省,也不能用 HTTP 轮询

信令服务器的职责只有一个:把 A 的会话描述和网络候选转发给 B。HTTP 轮询当然能完成“转发”,但延迟不可控,且多人房间里每个客户端都要频繁拉取,容易重复消费旧消息。更重要的是,offer、answer、candidate 三者之间有强顺序,一旦乱序,浏览器直接抛 Failed to set remote answer sdp 这类运行时报错。

所以常见做法是用 WebSocket 做信令通道。服务端主动推送 peer-joined 、 peer-left ,客户端通过 send 定向转发,天然满足房间型通话场景。下面是一个十分钟能跑通的最小实现。

2.2 最简 WebSocket 信令服务器:房间、加入、离开、转发

// server.js
const { WebSocketServer } = require('ws');

const wss = new WebSocketServer({ port: 8080 });
const rooms = new Map(); // roomId -> Map<userId, WebSocket>

function send(ws, obj) {
  ws.send(JSON.stringify(obj));
}

wss.on('connection', (ws) => {
  ws.userId = null;
  ws.roomId = null;

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw);

    if (msg.type === 'join') {
      ws.userId = msg.userId;
      ws.roomId = msg.roomId;
      if (!rooms.has(msg.roomId)) rooms.set(msg.roomId, new Map());
      const room = rooms.get(msg.roomId);

      // 通知房间内已有用户,并把已有用户列表回给新加入者
      for (const [uid, peer] of room) {
        send(peer, { type: 'peer-joined', userId: ws.userId });
        send(ws, { type: 'peer-joined', userId: uid });
      }
      room.set(ws.userId, ws);
      return;
    }

    if (msg.type === 'signal') {
      const room = rooms.get(ws.roomId);
      const target = room?.get(msg.to);
      if (target) {
        send(target, { type: 'signal', from: ws.userId, data: msg.data });
      }
      return;
    }

    if (msg.type === 'leave') {
      ws.close();
    }
  });

  ws.on('close', () => {
    if (!ws.roomId) return;
    const room = rooms.get(ws.roomId);
    if (!room) return;
    room.delete(ws.userId);
    for (const [uid, peer] of room) {
      send(peer, { type: 'peer-left', userId: ws.userId });
    }
    if (room.size === 0) rooms.delete(ws.roomId);
  });
});

这段代码里, rooms 是双层 Map:外层按房间号索引,内层按 userId 索引。这样做的原因是 signal 消息必须精确投递给 to 指定的用户,而不是广播给全房间。WebSocket 实例上挂 userId 和 roomId ,是为了在 close 事件里知道该清理哪张表。

需要扩展踢人、房间密码、断线重连时,只需要在这个结构上加校验逻辑,不需要改动媒体链路。消息体里的 data 字段原样 JSON 转发,服务器不解析 SDP,这能省掉大量报文兼容问题。

2.3 客户端完成 offer/answer/ICE 交换

客户端收到 signal 后,要按 data.type 走不同分支。下面是最核心的协商流程:

function sendSignal(to, data) {
  socket.send(JSON.stringify({ type: 'signal', to, data }));
}

async function handleSignal(from, data) {
  const pc = peerConnections.get(from) || createPeerConnection(from);

  if (data.type === 'offer') {
    await pc.setRemoteDescription(data);
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    sendSignal(from, { type: 'answer', sdp: pc.localDescription.sdp });
    return;
  }

  if (data.type === 'answer') {
    await pc.setRemoteDescription(data);
    return;
  }

  if (data.type === 'candidate') {
    // 远端候选可能在 offer/answer 之前到达,需要排队
    if (remoteDescriptionReady.has(from)) {
      try {
        await pc.addIceCandidate(data.candidate);
      } catch (err) {
        console.warn('addIceCandidate error', err);
      }
    } else {
      pendingCandidates.get(from).push(data.candidate);
    }
  }
}

注意 candidate 分支:如果 offer/answer 还没处理完,远端候选已经先到了,直接 addIceCandidate 会报 Error: ICE candidate added before remote description 。稳妥做法是按 from 维护一个候选队列,等 setRemoteDescription 成功后统一冲刷。

另一个细节是:发起方要先把 localStream 的轨道加进 pc ,再执行 createOffer() 。顺序反过来生成的 SDP 里没有媒体轨道,远端 ontrack 永远不会触发,问题肉眼很难定位。

2.4 协商阶段最容易被漏掉的 3 个参数

参数 推荐值 作用
iceServers STUN + 自建 TURN 决定 NAT 穿透和失败后的中继兜底
iceCandidatePoolSize 10 预生成 candidate,缩短连通时间
iceTransportPolicy 'all' 允许 host/srflx/relay 混合使用

iceCandidatePoolSize 默认是 0,浏览器按需生成候选,遇到网络质量差时首帧延迟会明显。把它设成 10,相当于提前用 ICE 协议收集几组候选,连接阶段少一轮等待。但它会提前占端口,服务器如有限流要关注。

iceTransportPolicy 如果设成 'relay' ,则所有媒体都强制走 TURN 中继,适合网络策略严格的办公网;正常情况下保留 'all' ,让 STUN 打洞优先,降低成本。这里最容易犯的错是只写了一个 STUN 地址,忽略 TURN。很多生产事故不是 JavaScript 代码逻辑错,而是 ICE 候选里没有 relay 类型,导致对称 NAT 下永远连不通。

3. 1对1视频通话:从 getUserMedia 到接通的状态机

信令通了,下一步是把摄像头和麦克风流接进来。1 对 1 视频的代码量大头在状态管理和 UI 反馈,而不是 WebRTC API 本身。

3.1 摄像头和麦克风约束参数设置

const constraints = {
  audio: {
    echoCancellation: true,
    noiseSuppression: true,
    autoGainControl: true
  },
  video: {
    width: { ideal: 1280, max: 1920 },
    height: { ideal: 720, max: 1080 },
    frameRate: { ideal: 30, max: 30 }
  }
};

const stream = await navigator.mediaDevices.getUserMedia(constraints);

ideal 是软性目标,浏览器会从摄像头实际支持的采集能力里挑一个最接近的值。低端笔记本摄像头不支持 1080p 时,会自动降到 720p,不会抛错;但如果 max 设成硬约束,摄像头满足不了,就会触发 NotReadableError 。

frameRate 的 max: 30 要谨慎。某些老旧摄像头最大只有 24 帧,硬约束会直接让获取媒体流失败。要么去掉 max ,要么把帧率降到 20~24。音频三项全开,是为了让笔记本内置麦克风在免提场景下减少回音。外部采集卡或专业麦克风反而需要关掉 autoGainControl ,否则音量会被自动压得很奇怪。

3.2 呼叫状态机和画面渲染的最小代码

通话页面至少两个 <video> 元素,一个是本地预览,一个是远端画面:

<video id="local" autoplay muted playsinline></video>
<video id="remote" autoplay playsinline></video>

本地视频必须加 muted ,不然麦克风声音会再从扬声器出来,形成回声。 playsinline 是移动端 Safari 不变成全屏播放的关键属性。

对应的 JS 状态机可以这样组织:

let phase = 'idle';
let pc = null;
let localStream = null;

function setPhase(next) {
  phase = next;
  // 根据 phase 控制按钮 disabled 状态
  document.querySelectorAll('button').forEach((b) => (b.disabled = false));
  if (phase === 'idle') answerBtn.disabled = true;
  if (phase === 'incoming') answerBtn.disabled = false;
  if (phase === 'calling' || phase === 'connected') callBtn.disabled = true;
}

async function createPeerConnection() {
  pc = new RTCPeerConnection(iceConfig);
  localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));

  pc.ontrack = (e) => {
    remoteVideo.srcObject = e.streams[0];
  };

  pc.onconnectionstatechange = () => {
    if (pc.connectionState === 'connected') setPhase('connected');
    if (pc.connectionState === 'failed') setPhase('idle');
  };

  return pc;
}

async function startCall() {
  setPhase('calling');
  localStream = await navigator.mediaDevices.getUserMedia(constraints);
  localVideo.srcObject = localStream;
  const peer = await createPeerConnection();
  const offer = await peer.createOffer();
  await peer.setLocalDescription(offer);
  sendSignal(remoteUserId, { type: 'offer', sdp: peer.localDescription.sdp });
}

用 phase 统一控制按钮和提示文字,比散落的 if (calling) 判断好维护得多。 connectionState 变化要从 new 一路走到 connected 才算成功;中间任何一步进入 failed 都可以直接清理 UI 回到空闲。

这里的 pc.ontrack 在大多数现代浏览器里能取到 e.streams[0] 。如果代码里用的是 pc.addTransceiver 而不是 addTrack ,部分场景 e.streams 可能为空,建议兜底:

remoteVideo.srcObject = e.streams[0] || new MediaStream([e.track]);

3.3 移动端兼容性配置:Safari、Chrome 和 HBuilderX 的差异

平台 常见问题 处理方式
iOS Safari 非 HTTPS 页面下 getUserMedia 不可用 必须部署到 HTTPS 或 localhost
iOS Safari 视频默认全屏播放 <video> 上加 playsinline
Android Chrome 远端视频不自动播放 ontrack 里手动调用 remoteVideo.play()
HBuilderX 内置浏览器 navigator.mediaDevices 为 undefined 确认访问地址是 http://localhost 而不是 file://

很多人在 HBuilderX 里写完页面,直接双击 HTML 预览,结果 navigator.mediaDevices 是 undefined ,这是安全上下文限制: file:// 协议拿不到摄像头权限。改成 HBuilderX 内置浏览器跑 localhost 即可。

混合 App 场景中,前端用 Capacitor 或 Cordova 套壳时,原生层要声明摄像头和麦克风权限。iOS 的 Info.plist 里配置 NSCameraUsageDescription 和 NSMicrophoneUsageDescription ,Android 的 AndroidManifest.xml 里请求 CAMERA 和 RECORD_AUDIO 。否则 WebView 能加载,但拿不到流。

3.4 把项目组织成别人能看懂的“源码+项目说明”

这类 zip 项目最容易踩的坑是启动命令不清晰。我习惯用下面这种目录结构,README 里只留必要信息:

webrtc-app/
├── server/
│   ├── index.js
│   └── config.js
├── public/
│   ├── index.html
│   ├── css/
│   └── js/
│       ├── client.js
│       ├── signaling.js
│       └── ui.js
├── config/
│   └── ice.json
└── README.md

signaling.js 只负责 WebSocket 连接和消息收发; client.js 管理 RTCPeerConnection 生命周期; ui.js 只操作 DOM。 ice.json 单独放 STUN/TURN 地址,部署时不用改代码。

README 里建议写清三件事:Node 版本、启动命令、ice.json 的配置格式。如果房间号需要在 URL 里传,也要写明,例如 ?room=demo1 。这样对方不需要逆向读代码就能跑起来,项目说明才算合格。

4. 多人视频通话:Mesh 架构下的连接池与 STUN/TURN 部署

多人视频不是把 1 对 1 代码包在循环里就完事。连接数、ICE 候选量、带宽占用都会成倍增长,所以要先认清架构边界。

4.1 Mesh、SFU、MCU 的取舍

Mesh 架构下,每个客户端与其他所有参与者各建一条 RTCPeerConnection,媒体流直接互传。它不需要部署额外的媒体服务器,纯 JavaScript 就能实现,所以是个人项目和课程设计的首选。

N 个人占用的连接总数是 N×(N−1)÷2:

人数 每人维持的连接数 全房间总连接数 是否适合 Mesh
2 1 1 完全没问题
4 3 6 适合小课堂
6 5 15 上行带宽开始吃紧
10 9 45 需严格限制码率

Mesh 的缺点是上行带宽和编码压力会重复。比如 4 人房间,每个人要同时编码 3 路视频上行,CPU 高的笔记本就会掉帧。超过 8 人建议换 SFU,例如 mediasoup 或 Janus;但那种方案要额外维护媒体服务器,不是纯前端项目。

4.2 以 remoteUserId 为键的 RTCPeerConnection 连接池

多人房间必须用一个 Map 保存每对远端连接,key 用 remoteUserId ,value 是 RTCPeerConnection :

class MeshRoom {
  constructor(userId, roomId) {
    this.userId = userId;
    this.roomId = roomId;
    this.stream = null;
    this.pcs = new Map();
  }

  setLocalStream(stream) {
    this.stream = stream;
  }

  createPc(remoteId) {
    if (this.pcs.has(remoteId)) return this.pcs.get(remoteId);

    const pc = new RTCPeerConnection(iceConfig);

    if (this.stream) {
      this.stream.getTracks().forEach((track) => pc.addTrack(track, this.stream));
    }

    pc.onicecandidate = (e) => {
      if (e.candidate) {
        sendSignal({ to: remoteId, type: 'candidate', candidate: e.candidate });
      }
    };

    pc.ontrack = (e) => {
      bindRemoteVideo(remoteId, e.streams[0]);
    };

    pc.onconnectionstatechange = () => {
      if (pc.connectionState === 'failed') this.removePc(remoteId);
    };

    this.pcs.set(remoteId, pc);
    return pc;
  }

  removePc(remoteId) {
    const pc = this.pcs.get(remoteId);
    if (pc) {
      pc.close();
      this.pcs.delete(remoteId);
    }
  }

  removeAll() {
    for (const id of this.pcs.keys()) this.removePc(id);
  }
}

收到信令服务器广播的 peer-joined 后,要防止两人同时发起 offer。常见做法是给连接双方排序,例如 userId 字典序小的作为发起方,大的只等 answer:

async function onPeerJoined(remoteId) {
  const pc = room.createPc(remoteId);

  if (room.userId < remoteId) {
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    sendSignal({ to: remoteId, type: 'offer', sdp: pc.localDescription.sdp });
  }
  // 否则等待对方发 offer
}

这个排序逻辑保证了同一对用户的连接只有一个 offer 方向,不会出现两边同时创建 offer 导致互相覆盖远端描述的问题。

4.3 STUN 与 TURN:从打洞到中继兜底

STUN 只做一件事:让客户端知道自己的公网 IP 和端口映射。它不转发媒体。当双方都在对称 NAT 后面时,STUN 打洞大概率失败,这时就需要 TURN 服务器中转媒体流量。

ICE 配置建议这样写:

const iceConfig = {
  iceServers: [
    { urls: 'stun:stun.example.com:3478' },
    {
      urls: 'turn:turn.example.com:3478?transport=udp',
      username: 'webrtc',
      credential: 'your-strong-password'
    },
    {
      urls: 'turn:turn.example.com:3478?transport=tcp',
      username: 'webrtc',
      credential: 'your-strong-password'
    }
  ],
  iceCandidatePoolSize: 10,
  bundlePolicy: 'max-bundle'
};

transport=udp 和 transport=tcp 两条都写,是为了兼容只放行 TCP 的企业网络。 bundlePolicy: 'max-bundle' 会让音频、视频、数据通道共用一条传输通路,降低候选数量和连接维护成本。

4.4 coturn 部署参数和防火墙注意事项

自建 TURN 时,coturn 是常见做法。用 Docker 启动时注意端口范围:

docker run -d --network=host \
  --name coturn \
  coturn/coturn \
  -n \
  --min-port=49160 \
  --max-port=49200 \
  --fingerprint \
  --lt-cred-mech \
  --realm=meeting.example.com \
  --user=webrtc:your-strong-password \
  --no-multicast-peers

--network=host 很关键。如果不用 host 网络,TURN 回给客户端的 relay 地址会变成容器内网 IP,客户端无法连接。 --min-port 和 --max-port 是媒体中转端口范围,必须连续开放,典型区间是 49160~49200。

部署后要开放防火墙:

# 信令和 TURN 控制端口
ufw allow 3478/udp
ufw allow 3478/tcp

# 媒体中转端口范围
ufw allow 49160:49200/udp
ufw allow 49160:49200/tcp

验证 TURN 服务是否正常,可以在 coturn 所在机器上执行:

turnutils_uclient -u webrtc -w your-strong-password -p 3478 turn.example.com

如果看到 success 相关输出,说明中继通道可用。生产环境建议把 --lt-cred-mech 的用户名密码放到环境变量里,不要写进 docker 命令历史。

5. 用 getStats 和 setParameters 调优通话质量

通话能通只是起点,真正让用户留下的是稳定性和清晰度。下面这套验证流程是我在每次联调后都会跑的。

5.1 getStats 快速定位延迟、抖动和丢包

async function checkStats() {
  const stats = await pc.getStats(null, true);

  stats.forEach((report) => {
    if (report.type === 'inbound-rtp' && report.kind === 'video') {
      console.log('jitter:', report.jitter, 'packetsLost:', report.packetsLost);
    }
    if (report.type === 'candidate-pair' && report.state === 'succeeded') {
      console.log('rtt:', report.currentRoundTripTime);
    }
  });
}

currentRoundTripTime 反映当前选中传输路径的往返延迟。大于 200ms 时画面会有明显延迟感,大于 500ms 基本无法正常交流。 jitter 是网络抖动,超过 0.03 秒就会影响音频播放的连续性,可以先排查 Wi-Fi 丢包或弱信号。 packetsLost 持续增长,说明带宽不足,需要降码率。

5.2 用 setParameters 在弱网下实时降码率

function limitVideoBitrate(pc, maxBitrate) {
  const sender = pc.getSenders().find((s) => s.track && s.track.kind === 'video');
  if (!sender) return;

  const params = sender.getParameters();
  if (!params.encodings || params.encodings.length === 0) return;

  params.encodings[0].maxBitrate = maxBitrate; // 单位 bps
  sender.setParameters(params).catch(console.error);
}

调用示例:当 packetsLost 增多时,把视频峰值码率从 800_000 降到 300_000 。这个操作不需要重新协商 SDP,比更换 getUserMedia 约束成本低得多。注意它限制的是峰值,不是平均码率;WebRTC 自带的带宽估计算法仍然会动态调整实际码率,手动设置 maxBitrate 只是给上限。

5.3 验证 TURN 是否真的被媒体路径选中

部署了 TURN 不代表一定被使用。通过 getStats 看实际选中的候选对:

let selected;

stats.forEach((report) => {
  if (report.type === 'transport' && report.selectedCandidatePairId) {
    selected = stats.get(report.selectedCandidatePairId);
  }
});

if (selected) {
  const local = stats.get(selected.localCandidateId);
  const remote = stats.get(selected.remoteCandidateId);
  console.log('local:', local.candidateType, 'remote:', remote.candidateType);
}

如果输出了 relay ,说明媒体走了 TURN 中继;如果一直是 srflx ,说明 STUN 打洞成功,TURN 并没有承担流量。很多项目部署完 coturn 以为已经在兜底,实际选中的还是 STUN 路径。当大型多人房间里始终没有 relay 候选时,先回头看 coturn 的 3478 端口和 UDP 防火墙,再检查 ice.json 里的 TURN 配置有没有真正下发到客户端。

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

Logo

邀请您加入社区

更多推荐