写一个最简单的 WebRTC Demo(实操篇)
本文是 WebRTC 系列专栏的第三篇,我们将动手实践,从零开始构建一个完整的 WebRTC 音视频通话 Demo。通过这个实战项目,你将深入理解 WebRTC 的工作流程。
目录
- 项目概述
- 获取摄像头与麦克风
- 建立 RTCPeerConnection
- 实现完整的 P2P 音视频通话
- 运行与测试
- 常见问题与调试
- 总结
1. 项目概述
1.1 我们要做什么?
我们将构建一个1 对 1 的实时音视频通话应用,包含以下功能:
- 获取本地摄像头和麦克风
- 建立 P2P 连接
- 实现双向音视频通话
- 支持挂断功能
1.2 技术栈
| 组件 | 技术选型 |
|---|---|
| 前端 | 原生 HTML/CSS/JavaScript |
| 信令服务器 | Node.js + WebSocket |
| WebRTC | 浏览器原生 API |
1.3 项目结构
webrtc-demo/ ├── server/ │ ├── package.json │ └── server.js # 信令服务器 ├── client/ │ ├── index.html # 页面结构 │ ├── style.css # 样式 │ └── main.js # WebRTC 逻辑 └── README.md2. 获取摄像头与麦克风
2.1 基础 API:getUserMedia
getUserMedia是获取媒体设备的核心 API。
// 最简单的用法asyncfunctiongetLocalStream(){try{conststream=awaitnavigator.mediaDevices.getUserMedia({video:true,audio:true});returnstream;}catch(error){console.error('获取媒体设备失败:',error);throwerror;}}2.2 处理权限和错误
asyncfunctiongetLocalStream(){// 检查浏览器支持if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){thrownewError('浏览器不支持 getUserMedia');}try{conststream=awaitnavigator.mediaDevices.getUserMedia({video:true,audio:true});returnstream;}catch(error){// 处理不同类型的错误switch(error.name){case'NotAllowedError':thrownewError('用户拒绝了摄像头/麦克风权限');case'NotFoundError':thrownewError('找不到摄像头或麦克风设备');case'NotReadableError':thrownewError('设备被其他应用占用');case'OverconstrainedError':thrownewError('设备不满足指定的约束条件');default:throwerror;}}}2.3 高级约束配置
constconstraints={video:{width:{min:640,ideal:1280,max:1920},height:{min:480,ideal:720,max:1080},frameRate:{ideal:30},facingMode:'user'// 前置摄像头},audio:{echoCancellation:true,// 回声消除noiseSuppression:true,// 噪声抑制autoGainControl:true// 自动增益}};conststream=awaitnavigator.mediaDevices.getUserMedia(constraints);2.4 显示本地视频
<videoid="localVideo"autoplaymutedplaysinline></video>constlocalVideo=document.getElementById('localVideo');conststream=awaitgetLocalStream();// 将媒体流绑定到 video 元素localVideo.srcObject=stream;⚠️注意:本地视频需要设置
muted属性,否则会产生回声。
3. 建立 RTCPeerConnection
3.1 创建 PeerConnection
constconfiguration={iceServers:[{urls:'stun:stun.l.google.com:19302'},{urls:'stun:stun1.l.google.com:19302'}]};constpeerConnection=newRTCPeerConnection(configuration);3.2 添加本地媒体轨道
// 将本地媒体流的所有轨道添加到 PeerConnectionlocalStream.getTracks().forEach(track=>{peerConnection.addTrack(track,localStream);});3.3 处理远端媒体流
constremoteVideo=document.getElementById('remoteVideo');peerConnection.ontrack=(event)=>{// 获取远端媒体流const[remoteStream]=event.streams;remoteVideo.srcObject=remoteStream;};3.4 Offer/Answer 交换
// 发起方:创建 OfferasyncfunctioncreateOffer(){constoffer=awaitpeerConnection.createOffer();awaitpeerConnection.setLocalDescription(offer);// 通过信令服务器发送 OffersendToSignalingServer({type:'offer',sdp:offer.sdp});}// 接收方:处理 Offer 并创建 AnswerasyncfunctionhandleOffer(offer){awaitpeerConnection.setRemoteDescription(newRTCSessionDescription(offer));constanswer=awaitpeerConnection.createAnswer();awaitpeerConnection.setLocalDescription(answer);// 通过信令服务器发送 AnswersendToSignalingServer({type:'answer',sdp:answer.sdp});}// 发起方:处理 AnswerasyncfunctionhandleAnswer(answer){awaitpeerConnection.setRemoteDescription(newRTCSessionDescription(answer));}3.5 ICE 候选交换
// 收集 ICE 候选peerConnection.onicecandidate=(event)=>{if(event.candidate){sendToSignalingServer({type:'candidate',candidate:event.candidate});}};// 添加远端 ICE 候选asyncfunctionhandleCandidate(candidate){awaitpeerConnection.addIceCandidate(newRTCIceCandidate(candidate));}4. 实现完整的 P2P 音视频通话
现在让我们把所有部分组合起来,创建一个完整的项目。
4.1 信令服务器 (server/server.js)
constWebSocket=require('ws');consthttp=require('http');// 创建 HTTP 服务器constserver=http.createServer();// 创建 WebSocket 服务器constwss=newWebSocket.Server({server});// 存储所有连接的客户端constclients=newMap();letclientIdCounter=0;wss.on('connection',(ws)=>{// 为每个客户端分配唯一 IDconstclientId=++clientIdCounter;clients.set(clientId,ws);console.log(`客户端${clientId}已连接,当前在线:${clients.size}`);// 通知客户端其 IDws.send(JSON.stringify({type:'welcome',clientId:clientId,clientCount:clients.size}));// 通知其他客户端有新用户加入broadcastExcept(clientId,{type:'user-joined',clientId:clientId,clientCount:clients.size});ws.on('message',(message)=>{try{constdata=JSON.parse(message);console.log(`收到来自客户端${clientId}的消息:`,data.type);// 转发消息给目标客户端if(data.target){consttargetWs=clients.get(data.target);if(targetWs&&targetWs.readyState===WebSocket.OPEN){targetWs.send(JSON.stringify({...data,from:clientId}));}}else{// 广播给所有其他客户端broadcastExcept(clientId,{...data,from:clientId});}}catch(error){console.error('消息解析错误:',error);}});ws.on('close',()=>{clients.delete(clientId);console.log(`客户端${clientId}已断开,当前在线:${clients.size}`);// 通知其他客户端broadcastExcept(clientId,{type:'user-left',clientId:clientId,clientCount:clients.size});});ws.on('error',(error)=>{console.error(`客户端${clientId}错误:`,error);});});// 广播消息给除指定客户端外的所有客户端functionbroadcastExcept(excludeId,message){clients.forEach((ws,id)=>{if(id!==excludeId&&ws.readyState===WebSocket.OPEN){ws.send(JSON.stringify(message));}});}constPORT=process.env.PORT||8080;server.listen(PORT,()=>{console.log(`信令服务器运行在 ws://localhost:${PORT}`);});4.2 package.json (server/package.json)
{"name":"webrtc-signaling-server","version":"1.0.0","description":"WebRTC 信令服务器","main":"server.js","scripts":{"start":"node server.js"},"dependencies":{"ws":"^8.14.2"}}4.3 HTML 页面 (client/index.html)
<!DOCTYPEhtml><htmllang="zh-CN"><head><metacharset="UTF-8"><metaname="viewport"content="width=device-width, initial-scale=1.0"><title>WebRTC 视频通话 Demo</title><linkrel="stylesheet"href="style.css"></head><body><divclass="container"><h1>WebRTC 视频通话</h1><!-- 状态显示 --><divclass="status-bar"><spanid="connectionStatus">未连接</span><spanid="clientInfo"></span></div><!-- 视频区域 --><divclass="video-container"><divclass="video-wrapper"><videoid="localVideo"autoplaymutedplaysinline></video><spanclass="video-label">本地视频</span></div><divclass="video-wrapper"><videoid="remoteVideo"autoplayplaysinline></video><spanclass="video-label">远端视频</span></div></div><!-- 控制按钮 --><divclass="controls"><buttonid="startBtn"class="btn btn-primary">开启摄像头</button><buttonid="callBtn"class="btn btn-success"disabled>发起通话</button><buttonid="hangupBtn"class="btn btn-danger"disabled>挂断</button></div><!-- 媒体控制 --><divclass="media-controls"><buttonid="toggleVideoBtn"class="btn btn-secondary"disabled>关闭视频</button><buttonid="toggleAudioBtn"class="btn btn-secondary"disabled>静音</button></div><!-- 日志区域 --><divclass="log-container"><h3>连接日志</h3><divid="logArea"></div></div></div><scriptsrc="main.js"></script></body></html>4.4 CSS 样式 (client/style.css)
*{margin:0;padding:0;box-sizing:border-box;}body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen,Ubuntu,sans-serif;background:linear-gradient(135deg,#1a1a2e 0%,#16213e 100%);min-height:100vh;color:#fff;}.container{max-width:1200px;margin:0 auto;padding:20px;}h1{text-align:center;margin-bottom:20px;font-size:2rem;background:linear-gradient(90deg,#00d2ff,#3a7bd5);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;}/* 状态栏 */.status-bar{display:flex;justify-content:space-between;align-items:center;background:rgba(255,255,255,0.1);padding:10px 20px;border-radius:10px;margin-bottom:20px;}#connectionStatus{padding:5px 15px;border-radius:20px;background:#e74c3c;font-size:0.9rem;}#connectionStatus.connected{background:#27ae60;}#connectionStatus.calling{background:#f39c12;}/* 视频容器 */.video-container{display:flex;gap:20px;justify-content:center;flex-wrap:wrap;margin-bottom:20px;}.video-wrapper{position:relative;background:#000;border-radius:15px;overflow:hidden;box-shadow:0 10px 30pxrgba(0,0,0,0.3);}.video-wrapper video{width:480px;height:360px;object-fit:cover;display:block;}.video-label{position:absolute;bottom:10px;left:10px;background:rgba(0,0,0,0.7);padding:5px 15px;border-radius:20px;font-size:0.85rem;}/* 按钮样式 */.controls, .media-controls{display:flex;gap:15px;justify-content:center;margin-bottom:15px;}.btn{padding:12px 30px;border:none;border-radius:25px;font-size:1rem;cursor:pointer;transition:all 0.3s ease;font-weight:600;}.btn:disabled{opacity:0.5;cursor:not-allowed;}.btn-primary{background:linear-gradient(90deg,#00d2ff,#3a7bd5);color:#fff;}.btn-primary:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 5px 20pxrgba(0,210,255,0.4);}.btn-success{background:linear-gradient(90deg,#11998e,#38ef7d);color:#fff;}.btn-success:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 5px 20pxrgba(56,239,125,0.4);}.btn-danger{background:linear-gradient(90deg,#eb3349,#f45c43);color:#fff;}.btn-danger:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 5px 20pxrgba(235,51,73,0.4);}.btn-secondary{background:rgba(255,255,255,0.2);color:#fff;}.btn-secondary:hover:not(:disabled){background:rgba(255,255,255,0.3);}.btn-secondary.active{background:#e74c3c;}/* 日志区域 */.log-container{background:rgba(0,0,0,0.3);border-radius:15px;padding:20px;margin-top:20px;}.log-container h3{margin-bottom:15px;font-size:1.1rem;color:#aaa;}#logArea{height:200px;overflow-y:auto;font-family:'Monaco','Menlo',monospace;font-size:0.85rem;line-height:1.6;}#logArea .log-item{padding:3px 0;border-bottom:1px solidrgba(255,255,255,0.05);}#logArea .log-time{color:#888;margin-right:10px;}#logArea .log-info{color:#3498db;}#logArea .log-success{color:#27ae60;}#logArea .log-warning{color:#f39c12;}#logArea .log-error{color:#e74c3c;}/* 响应式设计 */@media(max-width:768px){.video-wrapper video{width:100%;height:auto;aspect-ratio:4/3;}.controls, .media-controls{flex-wrap:wrap;}.btn{flex:1;min-width:120px;}}4.5 JavaScript 主逻辑 (client/main.js)
// ==================== 配置 ====================constSIGNALING_SERVER_URL='ws://localhost:8080';constICE_SERVERS={iceServers:[{urls:'stun:stun.l.google.com:19302'},{urls:'stun:stun1.l.google.com:19302'},{urls:'stun:stun2.l.google.com:19302'}]};// ==================== 全局变量 ====================letlocalStream=null;letpeerConnection=null;letsignalingSocket=null;letmyClientId=null;letremoteClientId=null;letisVideoEnabled=true;letisAudioEnabled=true;// ==================== DOM 元素 ====================constlocalVideo=document.getElementById('localVideo');constremoteVideo=document.getElementById('remoteVideo');conststartBtn=document.getElementById('startBtn');constcallBtn=document.getElementById('callBtn');consthangupBtn=document.getElementById('hangupBtn');consttoggleVideoBtn=document.getElementById('toggleVideoBtn');consttoggleAudioBtn=document.getElementById('toggleAudioBtn');constconnectionStatus=document.getElementById('connectionStatus');constclientInfo=document.getElementById('clientInfo');constlogArea=document.getElementById('logArea');// ==================== 日志函数 ====================functionlog(message,type='info'){consttime=newDate().toLocaleTimeString();constlogItem=document.createElement('div');logItem.className='log-item';logItem.innerHTML=`<span class="log-time">[${time}]</span><span class="log-${type}">${message}</span>`;logArea.appendChild(logItem);logArea.scrollTop=logArea.scrollHeight;console.log(`[${type.toUpperCase()}]${message}`);}// ==================== 状态更新 ====================functionupdateStatus(status,className=''){connectionStatus.textContent=status;connectionStatus.className=className;}// ==================== 信令服务器连接 ====================functionconnectSignalingServer(){log('正在连接信令服务器...');signalingSocket=newWebSocket(SIGNALING_SERVER_URL);signalingSocket.onopen=()=>{log('信令服务器连接成功','success');updateStatus('已连接','connected');};signalingSocket.onclose=()=>{log('信令服务器连接断开','warning');updateStatus('未连接');// 尝试重连setTimeout(connectSignalingServer,3000);};signalingSocket.onerror=(error)=>{log('信令服务器连接错误','error');};signalingSocket.onmessage=async(event)=>{constmessage=JSON.parse(event.data);awaithandleSignalingMessage(message);};}// ==================== 处理信令消息 ====================asyncfunctionhandleSignalingMessage(message){log(`收到信令消息:${message.type}`);switch(message.type){case'welcome':myClientId=message.clientId;clientInfo.textContent=`我的 ID:${myClientId}| 在线人数:${message.clientCount}`;log(`分配到客户端 ID:${myClientId}`,'success');break;case'user-joined':clientInfo.textContent=`我的 ID:${myClientId}| 在线人数:${message.clientCount}`;log(`用户${message.clientId}加入`,'info');if(localStream){callBtn.disabled=false;}break;case'user-left':clientInfo.textContent=`我的 ID:${myClientId}| 在线人数:${message.clientCount}`;log(`用户${message.clientId}离开`,'warning');if(message.clientId===remoteClientId){hangup();}break;case'offer':log(`收到来自用户${message.from}的通话请求`,'info');remoteClientId=message.from;awaithandleOffer(message);break;case'answer':log(`收到来自用户${message.from}的应答`,'success');awaithandleAnswer(message);break;case'candidate':awaithandleCandidate(message);break;case'hangup':log(`用户${message.from}挂断了通话`,'warning');hangup();break;}}// ==================== 发送信令消息 ====================functionsendSignalingMessage(message){if(signalingSocket&&signalingSocket.readyState===WebSocket.OPEN){signalingSocket.send(JSON.stringify(message));}}// ==================== 获取本地媒体流 ====================asyncfunctionstartLocalStream(){try{log('正在获取摄像头和麦克风...');localStream=awaitnavigator.mediaDevices.getUserMedia({video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30}},audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}});localVideo.srcObject=localStream;log('摄像头和麦克风获取成功','success');// 更新按钮状态startBtn.disabled=true;callBtn.disabled=false;toggleVideoBtn.disabled=false;toggleAudioBtn.disabled=false;}catch(error){log(`获取媒体设备失败:${error.message}`,'error');}}// ==================== 创建 PeerConnection ====================functioncreatePeerConnection(){log('创建 PeerConnection...');peerConnection=newRTCPeerConnection(ICE_SERVERS);// 添加本地轨道localStream.getTracks().forEach(track=>{peerConnection.addTrack(track,localStream);log(`添加本地轨道:${track.kind}`);});// ICE 候选事件peerConnection.onicecandidate=(event)=>{if(event.candidate){log(`发送 ICE 候选:${event.candidate.type||'unknown'}`);sendSignalingMessage({type:'candidate',target:remoteClientId,candidate:event.candidate});}};// ICE 连接状态变化peerConnection.oniceconnectionstatechange=()=>{conststate=peerConnection.iceConnectionState;log(`ICE 连接状态:${state}`);switch(state){case'checking':updateStatus('正在连接...','calling');break;case'connected':case'completed':updateStatus('通话中','connected');log('P2P 连接建立成功!','success');break;case'failed':log('连接失败','error');hangup();break;case'disconnected':log('连接断开','warning');break;}};// 连接状态变化peerConnection.onconnectionstatechange=()=>{log(`连接状态:${peerConnection.connectionState}`);};// 收到远端轨道peerConnection.ontrack=(event)=>{log(`收到远端轨道:${event.track.kind}`,'success');const[remoteStream]=event.streams;remoteVideo.srcObject=remoteStream;};returnpeerConnection;}// ==================== 发起通话 ====================asyncfunctioncall(){log('发起通话...');createPeerConnection();try{constoffer=awaitpeerConnection.createOffer();awaitpeerConnection.setLocalDescription(offer);log('发送 Offer...');sendSignalingMessage({type:'offer',sdp:offer.sdp});updateStatus('等待应答...','calling');callBtn.disabled=true;hangupBtn.disabled=false;}catch(error){log(`创建 Offer 失败:${error.message}`,'error');}}// ==================== 处理 Offer ====================asyncfunctionhandleOffer(message){createPeerConnection();try{awaitpeerConnection.setRemoteDescription(newRTCSessionDescription({type:'offer',sdp:message.sdp}));constanswer=awaitpeerConnection.createAnswer();awaitpeerConnection.setLocalDescription(answer);log('发送 Answer...');sendSignalingMessage({type:'answer',target:remoteClientId,sdp:answer.sdp});callBtn.disabled=true;hangupBtn.disabled=false;}catch(error){log(`处理 Offer 失败:${error.message}`,'error');}}// ==================== 处理 Answer ====================asyncfunctionhandleAnswer(message){try{awaitpeerConnection.setRemoteDescription(newRTCSessionDescription({type:'answer',sdp:message.sdp}));log('Answer 处理完成','success');}catch(error){log(`处理 Answer 失败:${error.message}`,'error');}}// ==================== 处理 ICE 候选 ====================asyncfunctionhandleCandidate(message){try{if(peerConnection&&message.candidate){awaitpeerConnection.addIceCandidate(newRTCIceCandidate(message.candidate));log('添加 ICE 候选成功');}}catch(error){log(`添加 ICE 候选失败:${error.message}`,'error');}}// ==================== 挂断 ====================functionhangup(){log('挂断通话');// 通知对方if(remoteClientId){sendSignalingMessage({type:'hangup',target:remoteClientId});}// 关闭 PeerConnectionif(peerConnection){peerConnection.close();peerConnection=null;}// 清除远端视频remoteVideo.srcObject=null;remoteClientId=null;// 更新按钮状态callBtn.disabled=false;hangupBtn.disabled=true;updateStatus('已连接','connected');}// ==================== 切换视频 ====================functiontoggleVideo(){if(localStream){constvideoTrack=localStream.getVideoTracks()[0];if(videoTrack){isVideoEnabled=!isVideoEnabled;videoTrack.enabled=isVideoEnabled;toggleVideoBtn.textContent=isVideoEnabled?'关闭视频':'开启视频';toggleVideoBtn.classList.toggle('active',!isVideoEnabled);log(`视频已${isVideoEnabled?'开启':'关闭'}`);}}}// ==================== 切换音频 ====================functiontoggleAudio(){if(localStream){constaudioTrack=localStream.getAudioTracks()[0];if(audioTrack){isAudioEnabled=!isAudioEnabled;audioTrack.enabled=isAudioEnabled;toggleAudioBtn.textContent=isAudioEnabled?'静音':'取消静音';toggleAudioBtn.classList.toggle('active',!isAudioEnabled);log(`音频已${isAudioEnabled?'开启':'静音'}`);}}}// ==================== 事件绑定 ====================startBtn.addEventListener('click',startLocalStream);callBtn.addEventListener('click',call);hangupBtn.addEventListener('click',hangup);toggleVideoBtn.addEventListener('click',toggleVideo);toggleAudioBtn.addEventListener('click',toggleAudio);// ==================== 初始化 ====================window.addEventListener('load',()=>{log('WebRTC Demo 初始化...');connectSignalingServer();});// 页面关闭时清理window.addEventListener('beforeunload',()=>{if(localStream){localStream.getTracks().forEach(track=>track.stop());}if(peerConnection){peerConnection.close();}if(signalingSocket){signalingSocket.close();}});5. 运行与测试
5.1 启动信令服务器
# 进入 server 目录cdserver# 安装依赖npminstall# 启动服务器npmstart输出:
信令服务器运行在 ws://localhost:80805.2 启动客户端
由于需要访问摄像头,浏览器要求使用 HTTPS 或 localhost。我们可以使用简单的 HTTP 服务器:
# 进入 client 目录cdclient# 使用 Python 启动 HTTP 服务器python3 -m http.server3000# 或使用 Node.js 的 http-servernpx http-server -p30005.3 测试步骤
- 打开两个浏览器窗口(或两台设备)
- 访问
http://localhost:3000 - 在两个窗口中分别点击「开启摄像头」
- 在其中一个窗口点击「发起通话」
- 观察连接建立过程和视频通话效果
5.4 测试检查清单
| 检查项 | 预期结果 |
|---|---|
| 本地视频显示 | ✅ 能看到自己的摄像头画面 |
| 信令连接 | ✅ 状态显示「已连接」 |
| 发起通话 | ✅ 状态变为「等待应答」 |
| 连接建立 | ✅ 状态变为「通话中」 |
| 远端视频 | ✅ 能看到对方的视频 |
| 音频通话 | ✅ 能听到对方的声音 |
| 挂断功能 | ✅ 能正常挂断并重新通话 |
6. 常见问题与调试
6.1 调试工具
Chrome WebRTC Internals
在 Chrome 浏览器中访问:
chrome://webrtc-internals可以查看:
- PeerConnection 状态
- ICE 候选收集情况
- SDP 内容
- 媒体统计信息
获取连接统计
asyncfunctiongetStats(){if(peerConnection){conststats=awaitpeerConnection.getStats();stats.forEach(report=>{if(report.type==='inbound-rtp'&&report.kind==='video'){console.log('视频接收统计:',{packetsReceived:report.packetsReceived,bytesReceived:report.bytesReceived,packetsLost:report.packetsLost,framesDecoded:report.framesDecoded});}});}}6.2 常见问题
问题 1:摄像头权限被拒绝
现象:点击「开启摄像头」后报错
解决方案:
- 检查浏览器地址栏的权限图标
- 确保使用
localhost或HTTPS - 在浏览器设置中重置摄像头权限
问题 2:ICE 连接失败
现象:状态一直显示「正在连接」
可能原因:
- 防火墙阻止 UDP 流量
- NAT 类型不兼容
- STUN 服务器不可用
解决方案:
// 添加 TURN 服务器作为备选constICE_SERVERS={iceServers:[{urls:'stun:stun.l.google.com:19302'},{urls:'turn:your-turn-server.com:3478',username:'user',credential:'password'}]};问题 3:只有单向视频
现象:一方能看到对方,但对方看不到自己
可能原因:
- 轨道未正确添加
ontrack事件未触发
调试方法:
// 检查轨道状态console.log('发送器:',peerConnection.getSenders());console.log('接收器:',peerConnection.getReceivers());问题 4:音频有回声
现象:通话时听到自己的声音
解决方案:
- 确保本地视频设置了
muted属性 - 使用耳机进行测试
- 检查
echoCancellation是否启用
<videoid="localVideo"autoplaymutedplaysinline></video>6.3 网络调试
// 监控 ICE 候选收集peerConnection.onicegatheringstatechange=()=>{console.log('ICE 收集状态:',peerConnection.iceGatheringState);};// 打印所有收集到的候选peerConnection.onicecandidate=(event)=>{if(event.candidate){console.log('ICE 候选:',{type:event.candidate.type,protocol:event.candidate.protocol,address:event.candidate.address,port:event.candidate.port});}else{console.log('ICE 候选收集完成');}};7. 总结
本文要点回顾
| 步骤 | 关键 API |
|---|---|
| 获取媒体流 | navigator.mediaDevices.getUserMedia() |
| 创建连接 | new RTCPeerConnection(config) |
| 添加轨道 | pc.addTrack(track, stream) |
| 创建 Offer | pc.createOffer() |
| 设置描述 | pc.setLocalDescription()/pc.setRemoteDescription() |
| ICE 候选 | pc.onicecandidate/pc.addIceCandidate() |
| 接收媒体 | pc.ontrack |
完整流程图
┌─────────────────────────────────────────────────────────────────┐ │ WebRTC 通话流程 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. getUserMedia() 获取本地媒体流 │ │ ↓ │ │ 2. new RTCPeerConnection() 创建连接对象 │ │ ↓ │ │ 3. addTrack() 添加本地轨道 │ │ ↓ │ │ 4. createOffer() 创建 Offer │ │ ↓ │ │ 5. setLocalDescription() 设置本地描述 │ │ ↓ │ │ 6. 信令服务器 交换 Offer/Answer/ICE │ │ ↓ │ │ 7. setRemoteDescription() 设置远端描述 │ │ ↓ │ │ 8. addIceCandidate() 添加 ICE 候选 │ │ ↓ │ │ 9. ontrack 接收远端媒体 │ │ ↓ │ │ 10. 通话建立! │ │ │ └─────────────────────────────────────────────────────────────────┘下一篇预告
在下一篇文章中,我们将深入探讨WebRTC 的三个关键技术:
- NAT 穿透原理与 ICE 框架
- 音视频实时传输协议(RTP/RTCP/SRTP)
- 回声消除、抗抖动与带宽控制
参考资料
- MDN - WebRTC API
- WebRTC Samples
- Getting Started with WebRTC
- WebRTC for the Curious