怎样用ps做企业网站,小网站建设,嘉兴制作网站软件,杭州工业设计公司排名前十强写一个最简单的 WebRTC Demo#xff08;实操篇#xff09; 本文是 WebRTC 系列专栏的第三篇#xff0c;我们将动手实践#xff0c;从零开始构建一个完整的 WebRTC 音视频通话 Demo。通过这个实战项目#xff0c;你将深入理解 WebRTC 的工作流程。 目录
项目概述获取摄像头…写一个最简单的 WebRTC Demo实操篇本文是 WebRTC 系列专栏的第三篇我们将动手实践从零开始构建一个完整的 WebRTC 音视频通话 Demo。通过这个实战项目你将深入理解 WebRTC 的工作流程。目录项目概述获取摄像头与麦克风建立 RTCPeerConnection实现完整的 P2P 音视频通话运行与测试常见问题与调试总结1. 项目概述1.1 我们要做什么我们将构建一个1 对 1 的实时音视频通话应用包含以下功能获取本地摄像头和麦克风建立 P2P 连接实现双向音视频通话支持挂断功能1.2 技术栈组件技术选型前端原生 HTML/CSS/JavaScript信令服务器Node.js WebSocketWebRTC浏览器原生 API1.3 项目结构webrtc-demo/ ├── server/ │ ├── package.json │ └── server.js # 信令服务器 ├── client/ │ ├── index.html # 页面结构 │ ├── style.css # 样式 │ └── main.js # WebRTC 逻辑 └── README.md2. 获取摄像头与麦克风2.1 基础 APIgetUserMediagetUserMedia是获取媒体设备的核心 API。// 最简单的用法asyncfunctiongetLocalStream(){try{conststreamawaitnavigator.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{conststreamawaitnavigator.mediaDevices.getUserMedia({video:true,audio:true});returnstream;}catch(error){// 处理不同类型的错误switch(error.name){caseNotAllowedError:thrownewError(用户拒绝了摄像头/麦克风权限);caseNotFoundError:thrownewError(找不到摄像头或麦克风设备);caseNotReadableError:thrownewError(设备被其他应用占用);caseOverconstrainedError: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// 自动增益}};conststreamawaitnavigator.mediaDevices.getUserMedia(constraints);2.4 显示本地视频videoidlocalVideoautoplaymutedplaysinline/videoconstlocalVideodocument.getElementById(localVideo);conststreamawaitgetLocalStream();// 将媒体流绑定到 video 元素localVideo.srcObjectstream;⚠️注意本地视频需要设置muted属性否则会产生回声。3. 建立 RTCPeerConnection3.1 创建 PeerConnectionconstconfiguration{iceServers:[{urls:stun:stun.l.google.com:19302},{urls:stun:stun1.l.google.com:19302}]};constpeerConnectionnewRTCPeerConnection(configuration);3.2 添加本地媒体轨道// 将本地媒体流的所有轨道添加到 PeerConnectionlocalStream.getTracks().forEach(track{peerConnection.addTrack(track,localStream);});3.3 处理远端媒体流constremoteVideodocument.getElementById(remoteVideo);peerConnection.ontrack(event){// 获取远端媒体流const[remoteStream]event.streams;remoteVideo.srcObjectremoteStream;};3.4 Offer/Answer 交换// 发起方创建 OfferasyncfunctioncreateOffer(){constofferawaitpeerConnection.createOffer();awaitpeerConnection.setLocalDescription(offer);// 通过信令服务器发送 OffersendToSignalingServer({type:offer,sdp:offer.sdp});}// 接收方处理 Offer 并创建 AnswerasyncfunctionhandleOffer(offer){awaitpeerConnection.setRemoteDescription(newRTCSessionDescription(offer));constanswerawaitpeerConnection.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)constWebSocketrequire(ws);consthttprequire(http);// 创建 HTTP 服务器constserverhttp.createServer();// 创建 WebSocket 服务器constwssnewWebSocket.Server({server});// 存储所有连接的客户端constclientsnewMap();letclientIdCounter0;wss.on(connection,(ws){// 为每个客户端分配唯一 IDconstclientIdclientIdCounter;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{constdataJSON.parse(message);console.log(收到来自客户端${clientId}的消息:,data.type);// 转发消息给目标客户端if(data.target){consttargetWsclients.get(data.target);if(targetWstargetWs.readyStateWebSocket.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!excludeIdws.readyStateWebSocket.OPEN){ws.send(JSON.stringify(message));}});}constPORTprocess.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)!DOCTYPEhtmlhtmllangzh-CNheadmetacharsetUTF-8metanameviewportcontentwidthdevice-width, initial-scale1.0titleWebRTC 视频通话 Demo/titlelinkrelstylesheethrefstyle.css/headbodydivclasscontainerh1WebRTC 视频通话/h1!-- 状态显示 --divclassstatus-barspanidconnectionStatus未连接/spanspanidclientInfo/span/div!-- 视频区域 --divclassvideo-containerdivclassvideo-wrappervideoidlocalVideoautoplaymutedplaysinline/videospanclassvideo-label本地视频/span/divdivclassvideo-wrappervideoidremoteVideoautoplayplaysinline/videospanclassvideo-label远端视频/span/div/div!-- 控制按钮 --divclasscontrolsbuttonidstartBtnclassbtn btn-primary开启摄像头/buttonbuttonidcallBtnclassbtn btn-successdisabled发起通话/buttonbuttonidhangupBtnclassbtn btn-dangerdisabled挂断/button/div!-- 媒体控制 --divclassmedia-controlsbuttonidtoggleVideoBtnclassbtn btn-secondarydisabled关闭视频/buttonbuttonidtoggleAudioBtnclassbtn btn-secondarydisabled静音/button/div!-- 日志区域 --divclasslog-containerh3连接日志/h3dividlogArea/div/div/divscriptsrcmain.js/script/body/html4.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_URLws://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}]};// 全局变量 letlocalStreamnull;letpeerConnectionnull;letsignalingSocketnull;letmyClientIdnull;letremoteClientIdnull;letisVideoEnabledtrue;letisAudioEnabledtrue;// DOM 元素 constlocalVideodocument.getElementById(localVideo);constremoteVideodocument.getElementById(remoteVideo);conststartBtndocument.getElementById(startBtn);constcallBtndocument.getElementById(callBtn);consthangupBtndocument.getElementById(hangupBtn);consttoggleVideoBtndocument.getElementById(toggleVideoBtn);consttoggleAudioBtndocument.getElementById(toggleAudioBtn);constconnectionStatusdocument.getElementById(connectionStatus);constclientInfodocument.getElementById(clientInfo);constlogAreadocument.getElementById(logArea);// 日志函数 functionlog(message,typeinfo){consttimenewDate().toLocaleTimeString();constlogItemdocument.createElement(div);logItem.classNamelog-item;logItem.innerHTMLspan classlog-time[${time}]/spanspan classlog-${type}${message}/span;logArea.appendChild(logItem);logArea.scrollToplogArea.scrollHeight;console.log([${type.toUpperCase()}]${message});}// 状态更新 functionupdateStatus(status,className){connectionStatus.textContentstatus;connectionStatus.classNameclassName;}// 信令服务器连接 functionconnectSignalingServer(){log(正在连接信令服务器...);signalingSocketnewWebSocket(SIGNALING_SERVER_URL);signalingSocket.onopen(){log(信令服务器连接成功,success);updateStatus(已连接,connected);};signalingSocket.onclose(){log(信令服务器连接断开,warning);updateStatus(未连接);// 尝试重连setTimeout(connectSignalingServer,3000);};signalingSocket.onerror(error){log(信令服务器连接错误,error);};signalingSocket.onmessageasync(event){constmessageJSON.parse(event.data);awaithandleSignalingMessage(message);};}// 处理信令消息 asyncfunctionhandleSignalingMessage(message){log(收到信令消息:${message.type});switch(message.type){casewelcome:myClientIdmessage.clientId;clientInfo.textContent我的 ID:${myClientId}| 在线人数:${message.clientCount};log(分配到客户端 ID:${myClientId},success);break;caseuser-joined:clientInfo.textContent我的 ID:${myClientId}| 在线人数:${message.clientCount};log(用户${message.clientId}加入,info);if(localStream){callBtn.disabledfalse;}break;caseuser-left:clientInfo.textContent我的 ID:${myClientId}| 在线人数:${message.clientCount};log(用户${message.clientId}离开,warning);if(message.clientIdremoteClientId){hangup();}break;caseoffer:log(收到来自用户${message.from}的通话请求,info);remoteClientIdmessage.from;awaithandleOffer(message);break;caseanswer:log(收到来自用户${message.from}的应答,success);awaithandleAnswer(message);break;casecandidate:awaithandleCandidate(message);break;casehangup:log(用户${message.from}挂断了通话,warning);hangup();break;}}// 发送信令消息 functionsendSignalingMessage(message){if(signalingSocketsignalingSocket.readyStateWebSocket.OPEN){signalingSocket.send(JSON.stringify(message));}}// 获取本地媒体流 asyncfunctionstartLocalStream(){try{log(正在获取摄像头和麦克风...);localStreamawaitnavigator.mediaDevices.getUserMedia({video:{width:{ideal:1280},height:{ideal:720},frameRate:{ideal:30}},audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true}});localVideo.srcObjectlocalStream;log(摄像头和麦克风获取成功,success);// 更新按钮状态startBtn.disabledtrue;callBtn.disabledfalse;toggleVideoBtn.disabledfalse;toggleAudioBtn.disabledfalse;}catch(error){log(获取媒体设备失败:${error.message},error);}}// 创建 PeerConnection functioncreatePeerConnection(){log(创建 PeerConnection...);peerConnectionnewRTCPeerConnection(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(){conststatepeerConnection.iceConnectionState;log(ICE 连接状态:${state});switch(state){casechecking:updateStatus(正在连接...,calling);break;caseconnected:casecompleted:updateStatus(通话中,connected);log(P2P 连接建立成功,success);break;casefailed:log(连接失败,error);hangup();break;casedisconnected:log(连接断开,warning);break;}};// 连接状态变化peerConnection.onconnectionstatechange(){log(连接状态:${peerConnection.connectionState});};// 收到远端轨道peerConnection.ontrack(event){log(收到远端轨道:${event.track.kind},success);const[remoteStream]event.streams;remoteVideo.srcObjectremoteStream;};returnpeerConnection;}// 发起通话 asyncfunctioncall(){log(发起通话...);createPeerConnection();try{constofferawaitpeerConnection.createOffer();awaitpeerConnection.setLocalDescription(offer);log(发送 Offer...);sendSignalingMessage({type:offer,sdp:offer.sdp});updateStatus(等待应答...,calling);callBtn.disabledtrue;hangupBtn.disabledfalse;}catch(error){log(创建 Offer 失败:${error.message},error);}}// 处理 Offer asyncfunctionhandleOffer(message){createPeerConnection();try{awaitpeerConnection.setRemoteDescription(newRTCSessionDescription({type:offer,sdp:message.sdp}));constanswerawaitpeerConnection.createAnswer();awaitpeerConnection.setLocalDescription(answer);log(发送 Answer...);sendSignalingMessage({type:answer,target:remoteClientId,sdp:answer.sdp});callBtn.disabledtrue;hangupBtn.disabledfalse;}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(peerConnectionmessage.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();peerConnectionnull;}// 清除远端视频remoteVideo.srcObjectnull;remoteClientIdnull;// 更新按钮状态callBtn.disabledfalse;hangupBtn.disabledtrue;updateStatus(已连接,connected);}// 切换视频 functiontoggleVideo(){if(localStream){constvideoTracklocalStream.getVideoTracks()[0];if(videoTrack){isVideoEnabled!isVideoEnabled;videoTrack.enabledisVideoEnabled;toggleVideoBtn.textContentisVideoEnabled?关闭视频:开启视频;toggleVideoBtn.classList.toggle(active,!isVideoEnabled);log(视频已${isVideoEnabled?开启:关闭});}}}// 切换音频 functiontoggleAudio(){if(localStream){constaudioTracklocalStream.getAudioTracks()[0];if(audioTrack){isAudioEnabled!isAudioEnabled;audioTrack.enabledisAudioEnabled;toggleAudioBtn.textContentisAudioEnabled?静音:取消静音;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(tracktrack.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){conststatsawaitpeerConnection.getStats();stats.forEach(report{if(report.typeinbound-rtpreport.kindvideo){console.log(视频接收统计:,{packetsReceived:report.packetsReceived,bytesReceived:report.bytesReceived,packetsLost:report.packetsLost,framesDecoded:report.framesDecoded});}});}}6.2 常见问题问题 1摄像头权限被拒绝现象点击「开启摄像头」后报错解决方案检查浏览器地址栏的权限图标确保使用localhost或HTTPS在浏览器设置中重置摄像头权限问题 2ICE 连接失败现象状态一直显示「正在连接」可能原因防火墙阻止 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是否启用videoidlocalVideoautoplaymutedplaysinline/video6.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回声消除、抗抖动与带宽控制参考资料MDN - WebRTC APIWebRTC SamplesGetting Started with WebRTCWebRTC for the Curious