尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WebRTC网页电话实战:从信令到音视频通话完整实现

WebRTC网页电话实战:从信令到音视频通话完整实现 最近在整理音视频通信方案时发现很多人对“电话”的理解还停留在传统 PSTN 或运营商 VoIP 上但在 Web 生态里我们完全可以用浏览器实现一个具备通话能力的“网页电话”。本文从 WebRTC 的核心原理开始一步步搭建一个浏览器对浏览器的实时语音通话 Demo包含信令服务、媒体采集、音视频协商、候选交换和挂断清理等完整流程最后还会整理常见问题与工程建议。如果你对实时音视频感兴趣或者正好需要在自己的项目中加入通话能力这篇文章可以当作一份可落地的入门到实战教程。1. 背景与核心概念1.1 从传统电话到网页电话传统电话系统依赖运营商网络电话机通过接入网连接到交换机再通过 SS7 信令在局间完成呼叫建立。整个体系非常成熟但建设成本和扩容成本高而且和互联网应用之间有一道明显的鸿沟。随着 Web 技术发展我们希望在网页里直接发起语音或视频通话像打电话一样方便又希望把通话能力和业务系统结合比如在线客服、在线教育、视频面试、远程问诊。这些场景背后都离不开一个关键词WebRTC。WebRTC 的全称是 Web Real-Time Communication也就是“网页实时通信”它由 W3C 和 IETF 共同推动把音频采集、视频采集、编解码、网络传输、回声消除、噪声抑制、自动增益等能力集成到了浏览器内核中。简单来说浏览器天然支持实时音视频通信不需要额外安装插件也不需要自己实现复杂的音视频算法。1.2 WebRTC 解决的核心问题做实时音视频通信最大的难点不是采集音视频而是保证音视频数据在复杂网络环境下能够低延迟、高质量地传输。传统 HTTP 请求是“请求-响应”模式延迟高不适合实时流。WebRTC 采用 UDP 作为主要传输通道并在此基础上实现了 SRTP 加密传输、NAT 穿透、拥塞控制和丢包重传机制。下面梳理 WebRTC 需要解决的几个核心问题媒体采集通过getUserMedia获取麦克风、摄像头、屏幕共享等媒体流。信令与协商两个浏览器要通话必须知道对方在哪里、使用什么格式传输这套信息通过信令交换。NAT 穿透大多数设备都在路由器或防火墙后面没有公网 IP需要 NAT 穿透技术找到可用的通信路径。传输与加密媒体数据走 UDP但在传输之前会进行 DTLS 握手和 SRTP 加密。用户体验回声消除、噪声抑制、自动增益、抖动缓冲、带宽自适应等能力直接影响通话质量。WebRTC 不是把“传输”这一个点做完就结束它是一套端到端的实时通信解决方案。开发和排查问题时需要从一个完整的通话链路上看问题。1.3 常见名词解释先统一几个容易混淆的名词后文会反复使用信令Signaling用来交换呼叫控制消息的通道比如“我要呼叫你”“我同意通话”“这是我的媒体描述”“这是我的网络候选”。SDPSession Description Protocol会话描述协议用来描述媒体流信息比如音频编码格式、采样率、通道数、传输地址等。WebRTC 中经常说“交换 SDP”本质上是两个浏览器互相告诉对方自己支持什么、想用什么。ICEInteractive Connectivity Establishment交互式连接建立用来发现并选择一条最优的网络路径进行媒体传输。STUNSession Traversal Utilities for NATNAT 会话穿越工具帮助客户端发现自己的公网地址和端口映射情况。TURNTraversal Using Relays around NATNAT 中继穿越当点对点直连失败时通过服务器中继转发媒体数据。RTCPeerConnectionWebRTC 中最重要的 API 对象负责管理整个点对点连接。MediaStream媒体流对象对应麦克风采集的音频流或摄像头采集的视频流。这里需要特别强调一个容易误解的点WebRTC 本身并不包含信令服务信令的发送方式完全可以由开发者自定义你可以用 WebSocket、Socket.IO、HTTP 轮询甚至邮件来传递信令。WebRTC 只负责媒体传输部分信令不是标准中强制的实现。2. 环境准备与版本说明既然是实战环境准备非常重要。WebRTC 是浏览器能力所以核心环境是浏览器和信令服务。本文以如下环境做演示组件说明操作系统Windows 10 / macOS / Linux 均可浏览器Chrome 或 Edge 最新版Firefox 也可以但建议优先 Chrome信令服务Node.js ws 库运行方式本地启动 WebSocket 服务同时托管静态页面网络要求本机演示不需要公网局域网或跨网络通话建议自建 STUN/TURNNode.js 版本建议 14 以上因为代码会用到较现代的语法。实际操作时如果你本机 Node.js 版本较老可以先升级或者用 nvm 管理版本。本文代码不依赖某个精确版本号重点是演示流程和思路请根据实际项目情况调整。安装依赖时只需要一个ws库。如果网络下载较慢也可以换成socket.io但ws更轻量代码也更好理解。npm init -y npm install ws项目目录结构webrtc-phone/ ├── package.json ├── server.js └── public/ ├── index.html └── client.js其中server.js是信令服务同时负责把public目录下的静态文件返回给浏览器index.html是通话页面client.js是前端逻辑。有一点需要提醒HTTPS 环境。getUserMedia在非 localhost 环境下要求页面必须是 HTTPS 才能调用麦克风。这是因为浏览器把麦克风、摄像头视为敏感权限。我们用http://localhost访问时浏览器会放行但如果你把页面部署到局域网或者服务器上必须配置 HTTPS 证书否则麦克风权限会被拒绝。3. 核心原理拆解3.1 一次完整通话的流程一次 WebRTC 语音通话从用户角度只是“点一下拨打按钮”但内部会经历多个阶段。为了便于理解可以把流程拆成下面几大步A 浏览器打开页面获取麦克风得到本地音频流。A 创建RTCPeerConnection添加本地音频流轨道。A 创建 Offer也就是一份 SDP 描述通过信令服务发送给 B。B 收到 Offer 后同样获取麦克风、创建RTCPeerConnection设置远端描述。B 创建 Answer把应答 SDP 通过信令返回给 A。双方开始进行 ICE 候选交换尝试建立网络连接。连接建立后双方媒体数据开始传输A 播放 B 的音频B 播放 A 的音频。任一方挂断关闭连接释放麦克风。这个流程看起来不复杂但每个步骤都隐藏着大量细节。下面重点说三个关键环节。3.2 SDP 协商SDP 负责描述“双方愿意用什么方式通信”。浏览器在创建RTCPeerConnection后可以通过createOffer生成一段 SDP。这里面的信息包括媒体的种类音频还是视频。编解码器比如 Opus、G722、PCMU、PCMA。采样率和通道数比如音频 48000 Hz、双通道。加密信息用于 DTLS 握手的指纹。传输地址包括本地候选地址信息以及 ICE 相关的 username fragment 和 password。A 把这段 Offer SDP 发给 BB 如果接受就调用setRemoteDescription再调用createAnswer生成 Answer SDP 返回。这个过程就叫“SDP 协商”。读者在这里容易犯一个错误以为 SDP 里的 IP 地址就是真正发包的地址。实际上 SDP 里的地址只是候选地址之一最终媒体数据走哪条路径由 ICE 决定。这也是很多人一开始看 SDP 觉得乱七八糟的原因不用急着逐行看懂抓住“媒体格式 候选信息”两大块即可。3.3 ICE 与 NAT 穿透ICE 的作用是在多个网络路径中挑一条最优路径。一个浏览器可能同时有多个网卡、多个网络环境比如 WiFi、有线、4G、虚拟网卡这些环境会形成很多候选地址。ICE 会把这些候选地址收集起来通过信令发送给对方然后双方按优先级逐一尝试。这里的难点在 NAT 穿透。大多数设备的 IP 是路由器分配的内网地址例如192.168.1.100。两个设备在不同局域网时不能用内网地址直接互通。STUN 服务器可以帮助设备发现自己的公网映射地址比如你的内网是192.168.1.100:50000通过 NAT 映射后公网看到的可能是120.20.30.40:12345。如果能用这个映射地址互通就不用 TURN 中继转发。但某些对称型 NAT 环境下STUN 无法穿越此时就必须部署 TURN 服务器。TURN 会分配一个公网地址媒体数据先发到 TURN再由 TURN 转发给对端。代价是带宽占用和延迟增加但保证了连通性。总结一下 ICE 候选类型候选类型含义说明host本机网卡地址局域网内互通通常使用srflxSTUN 反射地址NAT 映射后的公网地址relayTURN 中继地址通过服务器转发在开发调试时可以先在局域网内使用 host 候选。一旦涉及真正的公网通话STUN/TURN 是必须考虑的基础设施尤其是 TURN没有它很多用户会通话失败。3.4 音频体验为什么要单独关注WebRTC 音频和视频的技术侧重点有很大差异。视频更关注清晰度、帧率音频更关注连续性和清晰度。浏览器在采集音频时默认会做回声消除AEC、噪声抑制ANS、自动增益控制AGC这三点对通话体验至关重要。回声是怎么产生的呢对方说话的声音通过你的扬声器播出来又被你的麦克风采集进去如果不做回声消除对方就会听到自己的回声。浏览器通过 AEC 算法来消除这部分声音。实际开发时我们通常不要手工关闭这些能力默认配置即可。如果你在调试中觉得回音很重优先检查是否重复播放了远端音频流而不是怀疑浏览器算法。4. 完整实战实现一个浏览器语音通话 Demo4.1 设计说明本文的 Demo 包含两部分第一个场景是“单页自环测试”在一个页面里创建两个RTCPeerConnection让它们互相连接验证本地音频采集和播放链路是否正常。这个场景不需要网络和信令非常适合快速验证浏览器能力。第二个场景才是完整的“双浏览器电话”通过 WebSocket 信令服务实现两个页面之间的实时语音通话。先做自环测试的原因在于排错时网络问题往往和媒体问题混在一起。我们先把问题隔离确保本机音频链路通再接入信令和网络这样定位问题会快很多。4.2 编写信令服务我们使用ws库实现一个极简 WebSocket 信令服务。server.js完整代码如下// 文件路径webrtc-phone/server.js const http require(http); const fs require(fs); const path require(path); const WebSocket require(ws); // 创建 HTTP 服务用于托管静态页面 const server http.createServer((req, res) { const url req.url / ? /index.html : req.url; const filePath path.join(__dirname, public, url); fs.readFile(filePath, (err, data) { if (err) { res.writeHead(404, { Content-Type: text/plain; charsetutf-8 }); res.end(Not Found); return; } const ext path.extname(filePath); const contentType ext .html ? text/html; charsetutf-8 : ext .js ? application/javascript; charsetutf-8 : text/plain; charsetutf-8; res.writeHead(200, { Content-Type: contentType }); res.end(data); }); }); // 创建 WebSocket 信令服务 const wss new WebSocket.Server({ server }); // 房间管理每个房间是一个 Mapkey 为客户端 idvalue 为 WebSocket 对象 const rooms new Map(); function sendTo(socket, message) { if (socket.readyState WebSocket.OPEN) { socket.send(JSON.stringify(message)); } } function broadcastToRoom(roomId, senderId, message) { const room rooms.get(roomId); if (!room) return; room.forEach((socket, id) { if (id ! senderId) { sendTo(socket, message); } }); } wss.on(connection, (socket) { let currentRoom null; let currentId null; socket.on(message, (rawMessage) { const message JSON.parse(rawMessage.toString()); const { type, roomId, senderId, payload } message; switch (type) { case join: // 加入房间 currentRoom roomId; currentId senderId; if (!rooms.has(roomId)) { rooms.set(roomId, new Map()); } const room rooms.get(roomId); room.set(senderId, socket); // 通知房间内其他客户端有新用户加入 broadcastToRoom(roomId, senderId, { type: join, roomId, senderId, payload: { peerId: senderId } }); break; case offer: case answer: case candidate: case bye: // 转发给房间内其他客户端 broadcastToRoom(roomId, senderId, { type, roomId, senderId, payload }); break; default: break; } }); socket.on(close, () { if (currentRoom currentId) { const room rooms.get(currentRoom); if (room) { room.delete(currentId); broadcastToRoom(currentRoom, currentId, { type: bye, roomId: currentRoom, senderId: currentId, payload: {} }); if (room.size 0) { rooms.delete(currentRoom); } } } }); }); const PORT 3000; server.listen(PORT, () { console.log(信令服务已启动http://localhost:${PORT}); });这个信令服务做得非常简单核心逻辑如下rooms是房间 Map每个房间维护一个客户端 id 到 WebSocket 的对应关系。客户端发送join消息加入房间。offer、answer、candidate、bye这四类消息直接转发给同房间其他客户端。客户端断开时清理房间数据并通知其他人。实际生产环境不能这样“裸奔”需要做用户认证、消息校验、心跳保活、消息频率限制等。但在入门 Demo 中这套逻辑已经把信令的本质展示清楚了只负责转发不参与媒体数据处理。4.3 编写通话页面页面结构public/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleWebRTC 网页电话 Demo/title style body { font-family: Microsoft YaHei, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; line-height: 1.6; } .box { border: 1px solid #ddd; border-radius: 8px; padding: 16px; margin-bottom: 20px; } button { font-size: 14px; padding: 8px 16px; margin: 4px; cursor: pointer; } #status { font-weight: bold; } /style /head body h2WebRTC 语音电话 Demo/h2 div classbox h3第一步自环测试/h3 p在同一个页面内建立两个 RTCPeerConnection验证本地麦克风采集和扬声器播放。/p button idloopbackBtn开始自环测试/button button idloopbackStopBtn停止自环测试/button /div div classbox h3第二步双浏览器通话/h3 label房间号input idroomId value1001 //label label用户IDinput iduserId valueuser-a //label br /br / button idjoinBtn加入房间/button button idstartCallBtn开始通话/button button idhangupBtn挂断/button p状态span idstatus未连接/span/p /div div classbox h3本地音量/h3 canvas idlevelCanvas width600 height40/canvas p提示如果自环测试能听到自己的声音说明麦克风和扬声器链路正常。/p /div script src/client.js/script /body /html注意这里用了canvas显示本地音量这对验证麦克风是否真的在工作很有帮助。如果音量条一直不动说明麦克风可能没被采集到或者浏览器权限未开启。4.4 编写前端业务逻辑public/client.js是前端核心代码较长我按功能拆开说明。先定义全局变量和工具函数// 文件路径webrtc-phone/public/client.js const statusEl document.getElementById(status); const roomIdEl document.getElementById(roomId); const userIdEl document.getElementById(userId); const joinBtn document.getElementById(joinBtn); const startCallBtn document.getElementById(startCallBtn); const hangupBtn document.getElementById(hangupBtn); let localStream null; let pc null; let ws null; let roomId null; let userId null; let remoteUserId null; // 是否正在通话 let inCall false; function setStatus(text) { statusEl.textContent text; } function getIceServers() { // 生产环境请部署自己的 STUN/TURN 服务 return [ { urls: stun:stun.l.google.com:19302 } ]; }这里先说明getIceServers。浏览器在创建RTCPeerConnection时需要候选服务器列表。STUN 服务器只负责帮你找到公网映射地址不转发媒体数据。示例中使用了 Google 的公共 STUN仅用于演示生产环境不建议长期依赖第三方公共 STUN稳定性无法保证。接下来是自环测试// 自环测试在一个页面里创建连接验证媒体链路 let loopbackPc1 null; let loopbackPc2 null; document.getElementById(loopbackBtn).addEventListener(click, async () { const stream await getUserMediaStream(); if (!stream) return; loopbackPc1 new RTCPeerConnection({ iceServers: getIceServers() }); loopbackPc2 new RTCPeerConnection({ iceServers: getIceServers() }); // 把本地音频轨道添加到 pc1 stream.getTracks().forEach((track) { loopbackPc1.addTrack(track, stream); }); // pc2 收到远端轨道后播放 loopbackPc2.ontrack (event) { const audio document.createElement(audio); audio.srcObject event.streams[0]; audio.autoplay true; audio.controls true; document.body.appendChild(audio); console.log(环回音频已播放); }; // ICE 候选互相交换 loopbackPc1.onicecandidate (event) { if (event.candidate) { loopbackPc2.addIceCandidate(event.candidate).catch(console.error); } }; loopbackPc2.onicecandidate (event) { if (event.candidate) { loopbackPc1.addIceCandidate(event.candidate).catch(console.error); } }; const offer await loopbackPc1.createOffer(); await loopbackPc1.setLocalDescription(offer); await loopbackPc2.setRemoteDescription(offer); const answer await loopbackPc2.createAnswer(); await loopbackPc2.setLocalDescription(answer); await loopbackPc1.setRemoteDescription(answer); setStatus(自环测试中); });自环测试相当于把 A 和 B 放在同一个页面中。pc1相当于主叫pc2相当于被叫。pc1产生的 Offer 通过setRemoteDescription直接交给pc2不走网络。这样做的核心目的是验证音频链路从麦克风采集、编码、解码、播放整条链路是否正常。停止自环测试的代码如下document.getElementById(loopbackStopBtn).addEventListener(click, () { if (loopbackPc1) { loopbackPc1.close(); loopbackPc1 null; } if (loopbackPc2) { loopbackPc2.close(); loopbackPc2 null; } setStatus(自环测试已停止); });接下来是获取麦克风async function getUserMediaStream() { try { const stream await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: false }); return stream; } catch (err) { console.error(获取麦克风失败, err); setStatus(麦克风权限被拒绝或者没有可用麦克风); return null; } }这里echoCancellation、noiseSuppression、autoGainControl分别是回声消除、噪声抑制和自动增益。它们通常默认开启但为了明确意图我们在代码里显式声明。实时语音通话建议全部开启。音量波形显示const canvas document.getElementById(levelCanvas); const canvasCtx canvas.getContext(2d); function startVolumeMeter(stream) { const audioContext new AudioContext(); const source audioContext.createMediaStreamSource(stream); const analyser audioContext.createAnalyser(); analyser.fftSize 512; source.connect(analyser); const dataArray new Uint8Array(analyser.frequencyBinCount); function draw() { requestAnimationFrame(draw); analyser.getByteTimeDomainData(dataArray); let sum 0; for (let i 0; i dataArray.length; i) { const val (dataArray[i] - 128) / 128; sum val * val; } const rms Math.sqrt(sum / dataArray.length); canvasCtx.clearRect(0, 0, canvas.width, canvas.height); canvasCtx.fillStyle #4CAF50; canvasCtx.fillRect(0, 0, rms * canvas.width * 4, canvas.height); } draw(); }音量条的实际作用是帮我们快速判断麦克风是否真的采集到了声音。如果点击“开始通话”后对方听不到声音先看这个音量条有没有跳动。然后是信令逻辑function sendMessage(message) { if (ws ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify(message)); } } function connectSignaling() { return new Promise((resolve, reject) { ws new WebSocket(ws://localhost:3000); ws.onopen () resolve(); ws.onerror (event) { console.error(信令连接错误, event); reject(new Error(信令连接错误)); }; ws.onclose () { setStatus(信令连接已断开); }; ws.onmessage async (event) { const message JSON.parse(event.data); const { type, senderId, payload } message; if (type join) { // 有新用户加入房间 if (senderId ! userId) { remoteUserId senderId; setStatus(检测到用户 ${remoteUserId} 加入可以呼叫); } } if (type offer) { remoteUserId senderId; await handleOffer(payload); } if (type answer) { await handleAnswer(payload); } if (type candidate) { if (pc payload) { await pc.addIceCandidate(payload).catch((err) { console.error(addIceCandidate 失败, err); }); } } if (type bye) { setStatus(对方已挂断); endCall(); } }; }); }上面这段代码的关键是ws.onmessage。不同类型的消息走不同处理函数。offer和answer是 SDP 协商candidate是网络候选。注意candidate中有可能出现null代表候选收集完成这时不需要再addIceCandidate。joinBtn的逻辑joinBtn.addEventListener(click, async () { roomId roomIdEl.value.trim(); userId userIdEl.value.trim(); if (!roomId || !userId) { setStatus(请输入房间号和用户ID); return; } await connectSignaling(); sendMessage({ type: join, roomId, senderId: userId, payload: {} }); setStatus(已加入房间 ${roomId}等待对方加入); });startCallBtn的逻辑startCallBtn.addEventListener(click, async () { if (!ws || ws.readyState ! WebSocket.OPEN) { setStatus(请先加入房间); return; } if (!remoteUserId) { setStatus(房间内没有其他用户无法呼叫); return; } if (inCall) { setStatus(已经在通话中); return; } localStream await getUserMediaStream(); if (!localStream) return; startVolumeMeter(localStream); pc new RTCPeerConnection({ iceServers: getIceServers() }); localStream.getTracks().forEach((track) { pc.addTrack(track, localStream); }); pc.onicecandidate (event) { if (event.candidate) { sendMessage({ type: candidate, roomId, senderId: userId, payload: event.candidate }); } }; pc.ontrack (event) { const audio document.createElement(audio); audio.srcObject event.streams[0]; audio.autoplay true; document.body.appendChild(audio); }; pc.onconnectionstatechange () { if (pc.connectionState connected) { setStatus(通话已建立); } if (pc.connectionState disconnected || pc.connectionState failed) { setStatus(连接断开); } }; const offer await pc.createOffer(); await pc.setLocalDescription(offer); sendMessage({ type: offer, roomId, senderId: userId, payload: { sdp: offer.sdp, type: offer.type } }); inCall true; setStatus(已发起呼叫等待应答); });发起呼叫时需要注意顺序页面点击“开始通话”的用户是主叫主叫要获取本地流、创建RTCPeerConnection、创建 Offer、设置本地描述、然后通过信令发送 Offer。收到 Answer 逻辑如下async function handleAnswer(payload) { if (!pc) return; const answer new RTCSessionDescription(payload); await pc.setRemoteDescription(answer); setStatus(已收到应答正在建立连接); }被叫方收到 Offer 的逻辑async function handleOffer(payload) { // 如果是新呼叫先停止可能存在的旧连接 if (inCall) { await hangup(); } localStream await getUserMediaStream(); if (!localStream) return; startVolumeMeter(localStream); pc new RTCPeerConnection({ iceServers: getIceServers() }); localStream.getTracks().forEach((track) { pc.addTrack(track, localStream); }); pc.onicecandidate (event) { if (event.candidate) { sendMessage({ type: candidate, roomId, senderId: userId, payload: event.candidate }); } }; pc.ontrack (event) { const audio document.createElement(audio); audio.srcObject event.streams[0]; audio.autoplay true; document.body.appendChild(audio); }; pc.onconnectionstatechange () { if (pc.connectionState connected) { setStatus(通话已建立); } }; const offer new RTCSessionDescription(payload); await pc.setRemoteDescription(offer); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); sendMessage({ type: answer, roomId, senderId: userId, payload: { sdp: answer.sdp, type: answer.type } }); inCall true; setStatus(已应答正在建立连接); }挂断逻辑hangupBtn.addEventListener(click, async () { const prevRemote remoteUserId; await hangup(); if (ws ws.readyState WebSocket.OPEN) { sendMessage({ type: bye, roomId, senderId: userId, payload: {} }); } remoteUserId null; setStatus(已挂断); }); async function hangup() { inCall false; if (pc) { pc.close(); pc null; } if (localStream) { localStream.getTracks().forEach((track) track.stop()); localStream null; } } // 页面关闭时自动挂断 window.addEventListener(beforeunload, () { if (inCall) { sendMessage({ type: bye, roomId, senderId: userId, payload: {} }); } });挂断时有两件事必须做一是关闭RTCPeerConnection和媒体轨道释放麦克风二是通过信令通知对方“我挂断了”让对方也执行清理操作。很多初学者会忘记停止本地媒体轨道导致关闭页面后麦克风仍然被占用。4.5 运行与验证启动信令服务和页面node server.js打开浏览器访问http://localhost:3000验证步骤打开页面先点击“开始自环测试”。如果能在页面上听到自己的声音回放说明麦克风和扬声器链路正常。再开一个浏览器标签页访问同一个地址。第一个标签页输入用户 IDuser-a第二个标签页输入user-b房间号都填1001。两个标签页都点击“加入房间”。user-a 点击“开始通话”user-b 会收到 Offer。user-b 点击“开始通话”实际上在handleOffer中已经自动应答。可以看到页面状态变成“通话已建立”。对着麦克风说话另一个页面应能听到声音。说明一下我们这里用两个浏览器标签页模拟两台设备本质上它们处于同一个局域网网络候选都是 host 类型所以可以直接互通。如果要在两台不同电脑上测试需要保证两台电脑能访问同一个信令服务地址并且网络环境允许直连如果有一方在严格 NAT 后面还需要配置 TURN。5. 常见问题与排查思路WebRTC 开发最痛苦的地方是问题可能出在链路任意一环获取媒体、信令、SDP 协商、ICE、编码、播放。下面按现象列出最常见的几个问题。问题现象常见原因解决思路点击加入房间没反应WebSocket 连接失败检查 node server.js 是否启动控制台看是否有连接错误麦克风权限被拒绝页面不是 HTTPS或浏览器设置阻止localhost 下使用本地测试生产环境使用 HTTPS自环测试听不到声音扬声器静音、音量条不跳、音频播放元素没创建成功观察音量条检查浏览器是否授权麦克风能听到本地声音但对方听不到信令正常但媒体流没有正确传输检查 addTrack 和 onicecandidate 是否执行打印 ICE 状态通话建立后 1-2 秒断开ICE 候选没交换完全或网络路径不通检查 console 中 ICE候选日志生产环境配置 TURN音频有严重回声远端音量太大、回声消除未生效、页面重复播放音频检查 autoplay 与 audio 元素数量确认 echoCancellation 开启局域网两台设备无法通话防火墙拦截、双端不在同一网段、信令地址配置错误使用同一 WiFi关闭防火墙测试使用 TURN 中继下面是几个高频问题的详细排查过程。5.1 无法获取麦克风在浏览器控制台执行下面的命令看是否能拿到媒体流navigator.mediaDevices.getUserMedia({ audio: true }).then(console.log).catch(console.error)如果返回NotAllowedError说明权限被拒绝。如果返回NotFoundError说明没有检测到麦克风设备。检查系统录音权限、浏览器站点权限以及是否插入了音频设备。5.2 呼叫后一直处于“connecting”状态无法建立这种问题大概率是 ICE 候选没有成功交换。在pc.onicecandidate和ws.onmessage的 candidate 分支中分别打印日志确认双方是否都收到了对方的候选。常见原因STUN 服务器不可达导致没有生成 srflx 候选。TURN 服务器未配置导致严格 NAT 环境下无法穿越。信令服务转发candidate时消息结构不对前端解析失败。排查时可以直接在控制台输入pc.localDescription和pc.remoteDescription查看 SDP 中的候选地址是否合理。5.3 通话中出现杂音或回音如果使用的是耳机杂音问题可能会轻一些。如果使用外放回声消除压力很大。建议不要重复把同一份MediaStream绑到多个 audio 元素。确认创建getUserMedia时echoCancellation为 true。检查是否多个标签页共用了一个麦克风。5.4 页面关闭后麦克风仍然被占用浏览器在多标签页对设备占用有自己的调度策略通常页面关闭会释放媒体流但如果代码里没有主动停止 track可能短暂仍显示占用。规范做法是挂断时执行localStream.getTracks().forEach((track) track.stop());并清空全局引用。重要提醒不要在生产代码中直接调用window.close()来尝试释放资源这是错误思路。6. 最佳实践与工程建议上面 Demo 跑通只是第一步。如果要把“网页电话”能力落地到真实产品中下面这些工程建议值得认真考虑。6.1 信令服务要可靠且安全信令是通话的控制面一旦抖动或断线通话状态就会异常。生产环境应做到使用鉴权机制比如登录态 Token 房间权限校验避免他人恶意加入房间。增加心跳机制及时发现并清理死连接。对消息做频率限制防止刷消息导致服务器压力过大。使用 STOMP、MQTT 或 Socket.IO 等成熟协议直接基于裸ws会缺少很多工程能力。即使媒体数据走点对点通道信令本身也需要加密传输推荐使用wss://避免信令内容被窃听和篡改。6.2 TURN 服务器是生产环境的必需品很多初级团队只部署 STUN认为大多数网络能直连就够了。但在实际用户分布中对称型 NAT、企业防火墙、校园网等场景占比不低。没有 TURN这些用户的通话会失败。TURN 的成本主要在带宽。媒体数据经过 TURN 中继服务器带宽消耗等于双端通话带宽之和。所以在规模化部署时要考虑带宽计费、地域部署、负载均衡等问题。建议先跑通功能再根据用户网络分布逐步扩容 TURN 集群。6.3 音频参数和体验优化WebRTC 音频默认使用 Opus 编码具备很好的抗丢包能力和宽频语音质量。工程上可以关注以下几个点getUserMedia使用默认音频设备但在客户端中提供设备选择能力用户可能有多套麦克风、扬声器。回音、噪声问题在真实会议室场景中非常严重尽量使用耳机测试并通过setSinkId控制输出设备。遇到抱包或带宽不足时可以通过RTCRtpSender.getParameters调整编码码率。弱网环境下可以启用冗余编码比如 Opus 的inbandFec具体要看浏览器和编码器支持情况。6.4 避免媒体流泄漏页面中只要有远程音频流在播放就会占用资源。正确做法是在挂断时关闭RTCPeerConnection。停止本地所有 track。移除页面中动态创建的 audio 元素。置空全局引用。如果还需要做通话录音可以考虑通过MediaRecorder将MediaStream写入 WebM 文件但要先告知用户并获得授权这涉及到合规和隐私问题。6.5 权限与安全边界getUserMedia属于敏感权限必须遵守最小权限原则音频通话只需要audio: true不要顺手把video也打开。需要摄像头时再去申请视频权限不需要时及时释放。生产环境务必使用 HTTPS。除了浏览器强制要求外WebRTC 的 DTLS/SRTP 加密组合也需要运行在可信任的页面上否则用户身份和通话内容都可能受到中间人攻击威胁。7. 总结与下一步开发 WebRTC 语音通话最大的门槛不在 API而在于理解整条链路。本文通过一个最小 Demo 展示了从信令到媒体协商、从 ICE 候选到音频传输的完整过程。你至少应该掌握这样几条主线信令服务只负责消息转发SDP 和 ICE 候选都需要通过信令通道交换。RTCPeerConnection是连接的核心对象ontrack负责接收远端媒体onicecandidate负责发送本地网络候选。本地自环测试可以快速排除设备和浏览器问题是调试中非常有用的手段。生产环境必须考虑 STUN/TURN、HTTPS、鉴权、日志监控、TURN 带宽成本和设备兼容性。下一步可以从两个方向继续深入一是把 Demo 改成视频通话增加video: true和远端视频元素二是在服务端引入 SFU 架构比如 mediasoup、Janus、LiveKit 等满足多人会议场景。建议先用本文代码跑通一对一语音再逐步加入房间人数限制、通话计时、禁言、设备切换、信令重连等功能。实践过程中多关注控制台和网络日志把每一步的状态变化看明白你的 WebRTC 基础就真正扎实了。
返回列表