简介:一套基于JavaScript与WebRTC的跨平台音视频通话项目源码,适用于计算机、通信、人工智能、自动化等专业的课程设计、期末大作业或毕业设计,亦可作为WebRTC学习者从入门到进阶的实战参考。项目源自个人毕业设计,答辩评审分达98分,代码经过完整调试与测试,可稳定运行。实现了1对1视频、多人视频、视频直播、屏幕共享、视频会议、房间管理、权限管理等核心功能,并针对一对多、多人房间、广播等不同通话场景给出对应实现,方便对照学习信令交互与媒体流控制。压缩包共31个文件,以14个JavaScript文件、7个HTML页面、4个CSS样式表为主,另含SSL证书、密钥、Node依赖配置及项目说明文档,包体仅657KB,结构清晰,开箱即用。目前已有75人学习下载,基础较好的读者可直接在其上扩展多人互动、录制回放等个性化功能。

1. WebRTC实现跨平台音视频通话的难点不在媒体流,在信令

一个典型1对1视频通话,浏览器端采集摄像头、编码、发送、解码、渲染这套流程, RTCPeerConnection 十几行就能搭起来。真正让项目复杂度上升的是信令:两个位于不同 NAT 后面的浏览器,怎么知道对方在哪、用哪个编解码器、谁的 offer 先发、谁的状态是挂断。这部分 WebRTC 规范没有规定,留给开发者自己实现。这也是标题里“基于 JavaScript WebRTC 实现跨平台音视频通话源码 + 项目说明”这类项目,源码包常见、能跑通的却少的主要原因。本文按“协议理解 → 1对1最小实现 → 多人房间架构 → 参数调优与验证”这条路,把一套能落地的方案拆开讲,适合刚接手 WebRTC 项目、准备自己搭信令服务的前端和全栈工程师。

2. 信令协商与ICE收集:跑通1对1视频的握手阶段

2.1 WebRTC 会话建立的三个必要过程

一次完整的1对1视频通话,在媒体数据到达对端之前,双方需要依次完成三件事:信令交换、连接协商、候选地址收集。

信令交换用于传递 SDP (Session Description Protocol)和 ICE 候选。SDP 描述的是“我想用什么”:音频编码格式、视频分辨率、传输协议、方向(sendrecv / sendonly / recvonly)。ICE 候选描述的是“我在哪”:每个候选是一个 IP:端口 组合,包含传输协议和优先级。

RTCPeerConnection 内部已经实现了 SDP 生成、ICE 状态机、DTLS 握手、SRTP 加解密,但对外只暴露 createOffer() 、 setLocalDescription() 、 setRemoteDescription() 、 addIceCandidate() 这几个方法。也就是说,信令消息不能直接通过 P2P 通道传——它要走一条双方都能访问的独立通道,通常是 WebSocket。

// 简化版:offer 方生成并发送 SDP
const pc = new RTCPeerConnection({ iceServers: [] });
const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true });
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'offer', sdp: pc.localDescription }));

代码里 offerToReceiveAudio 和 offerToReceiveVideo 已不是必须参数,但保留它们能让老版本 Safari 和部分 Android WebView 的行为保持一致。 pc.localDescription 在 setLocalDescription 之后已经是标准格式,不需要自己拼字符串。

对端收到 offer 后做三件事: setRemoteDescription 写入对方 SDP, createAnswer 生成自己的 SDP,再通过信令通道把 answer 发回。

2.2 ICE 候选:STUN 和 TURN 的分工

iceServers 数组是很多项目出错的地方。如果留空,那么只有处于同一局域网、且没有 NAT 的两台设备能连通。跨网络场景必须配置 STUN 和 TURN。

STUN 的作用是让浏览器知道自己的公网映射地址。TURN 则是中继:当 P2P 打洞失败或企业防火墙限制 UDP 时,媒体流量改走 TURN 服务器转发。TURN 的开销很高,一台单核 1GB 的服务器扛不住几路 720p 视频,所以在选型时,TURN 只作为兜底。

{
  "iceServers": [
    { "urls": "stun:stun.cloudflare.com:3478" },
    {
      "urls": "turn:turn.example.com:3478",
      "username": "webrtc",
      "credential": "secret"
    }
  ]
}

iceTransportPolicy 默认为 all ,即同时尝试 host、srflx、relay 三类候选。如果业务场景是纯内网,可以显式设为 relay 强制只走 TURN,便于抓包排查;正常生产环境不要设 relay ,否则所有通话都绕道中继,延迟和带宽成本大涨。

ICE 状态的判断建议监听 connectionState 而不是旧的 iceConnectionState ,前者已经聚合了 ICE 和 DTLS 两层状态,判断更直接。

状态变化:new -> connecting -> connected -> disconnected -> failed

当回调进入 failed 时,不要马上提示用户通话失败。4G 切换 Wi-Fi、网线短暂断开都会触发 disconnected ,此时 P2P 通道会自动重新协商。正确做法是给 disconnected 设一个 8~10 秒的超时观察窗口,超时仍未恢复再提示并挂断。

2.3 信令消息类型的工程化定义

信令协议不建议直接用 type: 'offer' / 'answer' 这种裸定义。一旦进入多人房间,消息类型会迅速膨胀,建议按命名空间切分。

消息类型 方向 携带内容
room.join 客户端 -> 服务端 房间号、用户ID
room.joined 服务端 -> 客户端 当前房间成员列表
signal.offer 客户端 -> 服务端 SDP,目标用户ID
signal.answer 客户端 -> 服务端 应答 SDP,目标用户ID
signal.candidate 客户端 -> 服务端 ICE 候选,目标用户ID
signal.relay 服务端 -> 目标客户端 转发上一条 signal

服务端只做转发,不做 SDP 解析,这样信令服务可以保持无状态。业务上的“是否允许加入房间”“是不是房主”这类判断放在业务层,不要在信令层掺和。

3. 用JavaScript实现1对1视频通话的最小可运行代码

3.1 本地预览与媒体约束设置

先解决本端采集。 getUserMedia 拿到 MediaStream 后,设置 video.srcObject ,不要用 video.src = URL.createObjectURL(stream) ,后者在部分浏览器会触发内存泄漏警告。

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

const localStream = await navigator.mediaDevices.getUserMedia(constraints);
document.querySelector('#localVideo').srcObject = localStream;

参数说明: echoCancellation 必须在采集端开启,否则远端会听到你自己的回声; ideal 是期望值而非硬性值,浏览器会根据摄像头能力和网络带宽自行调整;如果强制写 exact: 1280 ,摄像头不支持时直接抛 OverconstrainedError ,不建议对移动端设备用 exact。

3.2 offer 方与 answer 方的完整流程

1对1通话需要两个页面互相连接。下面这段是 offer 方逻辑,信令通过 WebSocket 发给对方。

let pc;

async function startCall(targetId) {
  pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.cloudflare.com:3478' }
    ]
  });

  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });

  pc.ontrack = (event) => {
    document.querySelector('#remoteVideo').srcObject = event.streams[0];
  };

  pc.onicecandidate = (event) => {
    if (event.candidate) {
      ws.send(JSON.stringify({
        type: 'candidate',
        target: targetId,
        candidate: event.candidate
      }));
    }
  };

  // 本方持有所有候选之后才做 setLocalDescription,是常见误区
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  ws.send(JSON.stringify({
    type: 'offer',
    target: targetId,
    sdp: pc.localDescription
  }));
}

这里的关键点在于 setLocalDescription 的时机。它不等待 ICE 候选收集完成, localDescription.sdp 中此时可能是 ice-ufrag 已生成但候选为空的状态。候选会通过 onicecandidate 逐个发送,所以对方必须能在收到 offer 之后、answer 之前持续接收 candidate 消息。若你对端在 setRemoteDescription 之前就调用了 addIceCandidate ,浏览器会报 InvalidStateError 。常见做法是:

let remoteCandidates = [];

async function onRemoteOffer(message) {
  await pc.setRemoteDescription(new RTCSessionDescription(message.sdp));
  remoteCandidates.forEach(c => pc.addIceCandidate(c));
  remoteCandidates = [];

  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  ws.send(JSON.stringify({ type: 'answer', target: message.from, sdp: pc.localDescription }));
}

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'candidate') {
    if (pc && pc.remoteDescription) {
      pc.addIceCandidate(msg.candidate);
    } else {
      remoteCandidates.push(msg.candidate);
    }
  }
};

这段代码把候选先缓冲起来,等 setRemoteDescription 完成后再统一灌入,绕开了竞态问题。很多项目通话建立不了,排查到最后都是这个细节。

3.3 带宽与解码参数的兜底配置

RTCPeerConnection 不提供直接设置视频码率的 API,必须通过修改 SDP 中的 b=AS 行来控制。这在多人场景很关键,否则上行带宽被一路 1080p 占满。

function applyBandwidth(sdp, videoBitrateKbps = 600) {
  let lines = sdp.split('\n');
  let inVideo = false;
  for (let i = 0; i < lines.length; i++) {
    if (lines[i].startsWith('m=video')) {
      inVideo = true;
      continue;
    }
    if (inVideo && lines[i].startsWith('m=')) {
      break;
    }
    if (inVideo && lines[i].startsWith('b=AS')) {
      lines[i] = `b=AS:${videoBitrateKbps}`;
      inVideo = false;
    }
  }
  return lines.join('\n');
}

注意:新版浏览器已经开始用 b=TIAS 或 RTP 头扩展的 transport-wide-cc 控制码率,直接改 b=AS 的效果逐渐减弱。它仍然适用于 Firefox 和部分 Android WebView,但在 Chrome 中建议结合 RTCRtpSender.setParameters 控制 encodings.maxBitrate 。

const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
if (params.encodings.length > 0) {
  params.encodings[0].maxBitrate = 600_000;
  params.encodings[0].maxFramerate = 24;
  await sender.setParameters(params);
}

maxBitrate 单位是 bps,写 600_000 即 600kbps。 maxFramerate 限制帧率,移动端弱网下建议设为 20 或 24,优先保分辨率。

4. 从1对1到多人视频:SFU选型与房间状态管理

4.1 Mesh 与 SFU 的取舍

1对1场景下,两端直接 P2P 是最高效的。到了多人视频,如果仍然两两互联——这就是 Mesh 结构——每个客户端同时上传一路视频、下载 N-1 路视频。4 人会议时,每个客户端要同时编解码 3 路下行流,带宽和 CPU 都扛不住。生产环境的多人通话几乎都采用 SFU(Selective Forwarding Unit)架构:每个客户端只上传一路,SFU 负责把视频流复制转发给房间内其他人。

维度 Mesh (P2P) SFU
上行带宽 1路 1路
下行带宽 (N-1)路 (N-1)路
服务端压力 无 高,需中转
多流适应能力 弱 强,可按需转发
典型实现 无额外服务 mediasoup, Janus, LiveKit

标题中的“多人视频”实现,常见做法是用 mediasoup 作为 SFU,前端接入方式与纯 P2P 不同——不是建立 N-1 个 RTCPeerConnection ,而是每个客户端与 mediasoup 的 Worker 建立一个连接,通过 transport.produce() 发送轨,通过 transport.consume() 接收房间里的其他轨。

客户端 A -> mediasoup Router -> 复制流转发 -> 客户端 B/C/D

4.2 基于 mediasoup 的房间成员管理

使用 mediasoup 时,前端需要和信令服务配合完成四步:

  1. 创建或加入房间,向服务端请求当前成员列表。
  2. 创建 Device 实例,加载服务端返回的 routerRtpCapabilities 。
  3. 创建 SendTransport ,初始化本地生产轨。
  4. 创建 RecvTransport ,订阅房间内已有的消费轨。
import { Device } from 'mediasoup-client';

const device = new Device();
await device.load({ routerRtpCapabilities: serverRouterCapabilities });

const sendTransport = device.createSendTransport({
  id: serverSendTransport.id,
  iceParameters: serverSendTransport.iceParameters,
  dtlsParameters: serverSendTransport.dtlsParameters,
  sctpParameters: serverSendTransport.sctpParameters,
  iceServers: [],
  appData: { roomId }
});

// 收到服务端返回的 connect 回调
sendTransport.on('connect', ({ dtlsParameters }, callback, errback) => {
  ws.emit('transport-connect', {
    transportId: sendTransport.id,
    dtlsParameters
  }, callback, errback);
});

sendTransport.on('produce', ({ kind, rtpParameters }, callback, errback) => {
  ws.emit('transport-produce', {
    transportId: sendTransport.id,
    kind,
    rtpParameters
  }, callback, errback);
});

connect 和 produce 回调里的 callback 必须等服务端返回后才调用,所以这里的信令通道建议用带 ack 的消息格式,不能只发不管。 ui 多线程模型下,这两个事件不会自动触发,前端写得对不对,直接看事件是否到达。

4.3 房间状态与用户进退场

多人房间的复杂度主要在于状态同步。每个人的进房、退房、切屏、码率变化,都要通过信令广播。建议前端维护一个 Map<userId, RemoteTrack> 结构,成员列表变化时做差量更新,不要重建整个 mediasoup 连接。

const remoteConsumers = new Map();

async function onNewPeer(peerId) {
  const { consumer, producerId } = await ws.request('consume', {
    peerId,
    rtpCapabilities: device.rtpCapabilities
  });

  const track = consumer.track;
  const videoEl = createVideoElement(peerId);
  videoEl.srcObject = new MediaStream([track]);

  remoteConsumers.set(peerId, { consumer, videoEl });

  consumer.on('trackended', () => cleanupPeer(peerId));
  consumer.on('transportclose', () => cleanupPeer(peerId));
}

ws.request 这个封装一定要做超时处理。服务端如果在 30 秒内无法返回可消费的视频轨——比如房内那个成员断网了——前端可能一直挂起等待。建议统一封装为 Promise.race([request(), timeout]) 。

4.4 多人场景的码率自适应策略

SFU 的优势在于可以按订阅者的网络状况转发不同规格的流。参加过实际项目都会遇到这样的问题:房间里有 6 个人,其中 2 个人在农村 4G 环境,他们投诉“画面糊”。原因是 SFU 默认转发 720p 源流,弱网端虽然会触发抗丢包重传,但带宽不足时画面持续卡顿。

常见做法是开启 simulcast :发送端上报多路不同分辨率的编码流,比如 180p、360p、720p,SFU 根据每个接收端的探测带宽,选择转发哪一层。

// 服务端 consume 时使用的适配逻辑片段
const consumer = await transport.consume({
  producerId,
  rtpCapabilities,
  paused: false,
  adaptivePause: true
});

// 接收端需要主动选择层
consumer.setPreferredLayers({ spatialLayer: 0, temporalLayer: 1 });

sptialLayer 对应分辨率层,0 为最小层; temporalLayer 对应帧率层,值越小帧率越低。新加入的成员建议从 { spatialLayer: 1, temporalLayer: 1 } 起步,不要直接选最高层,避免加入房间时冲击网络。

5. 参数校准、网络切换与上线前的自测清单

5.1 用 getStats 判断卡顿归因

通话中用户报“卡”,不能直接怀疑 SFU。用 RTCPeerConnection.getStats() 可以区分是上行丢包、下行丢包还是 CPU 编码超时。

const stats = await pc.getStats();
stats.forEach(report => {
  if (report.type === 'inbound-rtp' && report.kind === 'video') {
    const loss = report.packetsLost / (report.packetsReceived + report.packetsLost);
    const jitterMs = report.jitter * 1000;
    console.log(`下行丢包率: ${(loss * 100).toFixed(2)}%,抖动: ${jitterMs.toFixed(0)}ms`);
  }
  if (report.type === 'outbound-rtp' && report.kind === 'video') {
    const bandwidth = (report.bytesSent * 8 / report.timestamp) / 1000;
    console.log(`上行码率约: ${bandwidth.toFixed(0)}kbps`);
  }
});

packetsLost 与 packetsReceived 同时计数成功与丢失,算出的丢包率能直接定位到是哪一侧的问题。抖动超过 80ms 就该考虑是否切换 TURN 或降低订阅层。

5.2 网络切换的专项处理

移动端 App 或 H5 页面在通话中切换 Wi-Fi 和 4G 时,网络路径变化,ICE 会重新协商。此时 connectionState 会从 connected 跳到 disconnected ,几秒后恢复 connected 。这个过程中,视频会短暂黑屏或静音,这是正常现象。不要让 UI 直接显示“通话已中断”,而是设置一个状态机:

let reconnectTimer;

pc.onconnectionstatechange = () => {
  if (pc.connectionState === 'disconnected') {
    map.joinRoomState.setStatus('网络切换中');
    reconnectTimer = setTimeout(() => {
      if (pc.connectionState === 'disconnected') {
        map.joinRoomState.setStatus('连接已断开');
        hangUp();
      }
    }, 8000);
  }
  if (pc.connectionState === 'connected') {
    if (reconnectTimer) {
      clearTimeout(reconnectTimer);
      reconnectTimer = null;
    }
    map.joinRoomState.setStatus('已连接');
  }
};

专题说明: connectionState 的 disconnected 不一定是灾难,但等待 8 秒后仍无恢复,再执行 pc.close() 并重新发起呼叫,成功率高于在 failed 状态直接挂断。

5.3 上线前必查的三项实测

第一项是双机不同网验证。不要只在同一 Wi-Fi 下测,要在两个不同运营商的 4G 热点下测通话建立速度。第二项是弱网模拟,用 Network Link Conditioner 或 Linux 的 tc 命令给服务端网卡加 2% 丢包、150ms 延迟,观察 SFU 转发是否会触发丢包重传导致延迟翻倍。第三项是服务端带宽监控,TURN 和 SFU 的流量是明帐——一路 720p 约 1.5Mbps 上行、房间内每人 1.5Mbps 下行,10 人房间总下行 15Mbps,一台 5Mbps 出口的服务器必然跑不动。

最后记住一点:任何 WebRTC 项目上线前,先在 chrome://webrtc-internals 里录一次完整通话,导出 JSON 存档。等线上出问题时,这份本地基线文件能帮你快速区分是不是信令、网络或 SFU 三者之一出了问题。

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

Logo

邀请您加入社区

更多推荐