WebRTC JavaScript实战:信令服务器、多人视频通话与STUN/TURN部署
简介:基于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 配置有没有真正下发到客户端。
更多推荐

所有评论(0)