news 2026/1/22 1:57:12

写一个最简单的 WebRTC Demo(实操篇)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
写一个最简单的 WebRTC Demo(实操篇)

写一个最简单的 WebRTC Demo(实操篇)

本文是 WebRTC 系列专栏的第三篇,我们将动手实践,从零开始构建一个完整的 WebRTC 音视频通话 Demo。通过这个实战项目,你将深入理解 WebRTC 的工作流程。


目录

  1. 项目概述
  2. 获取摄像头与麦克风
  3. 建立 RTCPeerConnection
  4. 实现完整的 P2P 音视频通话
  5. 运行与测试
  6. 常见问题与调试
  7. 总结

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.md

2. 获取摄像头与麦克风

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:8080

5.2 启动客户端

由于需要访问摄像头,浏览器要求使用 HTTPS 或 localhost。我们可以使用简单的 HTTP 服务器:

# 进入 client 目录cdclient# 使用 Python 启动 HTTP 服务器python3 -m http.server3000# 或使用 Node.js 的 http-servernpx http-server -p3000

5.3 测试步骤

  1. 打开两个浏览器窗口(或两台设备)
  2. 访问http://localhost:3000
  3. 在两个窗口中分别点击「开启摄像头」
  4. 在其中一个窗口点击「发起通话」
  5. 观察连接建立过程和视频通话效果

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:摄像头权限被拒绝

现象:点击「开启摄像头」后报错

解决方案

  1. 检查浏览器地址栏的权限图标
  2. 确保使用localhostHTTPS
  3. 在浏览器设置中重置摄像头权限
问题 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:音频有回声

现象:通话时听到自己的声音

解决方案

  1. 确保本地视频设置了muted属性
  2. 使用耳机进行测试
  3. 检查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)
创建 Offerpc.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)
  • 回声消除、抗抖动与带宽控制

参考资料

  1. MDN - WebRTC API
  2. WebRTC Samples
  3. Getting Started with WebRTC
  4. WebRTC for the Curious

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/1/17 6:02:34

QtWebEngine 自动重启方案

公众号&#xff1a;cpp手艺人 QtWebEngine 自动重启方案 在实际项目中不可避免的会遇到QWebengine崩溃和假死的问题。 在无法避免的情况下&#xff0c;我们一种可靠的机制能够重启。 由于QtWebEngine 使用多进程架构&#xff0c;渲染进程由 QWebEngineProcess.exe 负责。当渲染…

作者头像 李华
网站建设 2026/1/17 8:25:50

【dz-966】基于STM32的小区车库防涝系统设计

摘要 随着极端天气频发&#xff0c;小区车库涝灾风险显著增加&#xff0c;严重威胁车辆安全与居民财产。传统车库防涝多依赖人工巡查和手动操作挡杆、水泵&#xff0c;存在响应滞后、预警不及时等问题&#xff0c;难以应对突发暴雨引发的积水险情。​ 基于 STM32F103C8T6 单片…

作者头像 李华
网站建设 2026/1/21 13:48:53

回归单体架构到底是不是技术倒退

前言 软件技术发展这么多年,我们经历了单体,再到SOA,再到微服务的架构转变,这些变化的实践发起者都是用户规模庞大的大型企业,引来行业无数中小公司的效仿。可近些年出现了这样一个现象。那些已经迁移到微服务的公司,逐渐在试着回归单体架构。这不仅是中小公司的选择,像…

作者头像 李华
网站建设 2026/1/16 4:41:06

SQL语句执行很慢,如何分析呢?

一条SQL执行很慢&#xff0c;我们通常会使用MySQL的EXPLAIN命令来分析这条SQL的执行情况。通过key和key_len可以检查是否命中了索引&#xff0c;如果已经添加了索引&#xff0c;也可以判断索引是否有效。通过type字段可以查看SQL是否有优化空间&#xff0c;比如是否存在全索引扫…

作者头像 李华
网站建设 2026/1/14 21:55:21

iOS In-App Purchase 自动续订订阅完整实现指南

前言 自动续订订阅(Auto-Renewable Subscriptions)是 iOS 应用最常见的变现模式之一,适用于流媒体服务、云存储、会员权益等场景。相比一次性购买,订阅模式能够为开发者提供稳定的现金流,同时也为用户提供持续更新的服务体验。 本文将从零开始,全面讲解自动续订订阅的实…

作者头像 李华