☰
WebRTC零基础实战:从信令到一对一通话与排错
2026/10/29 11:57:00 网站建设 项目流程

WebRTC 这个技术,我最早接触是因为要做一个网页端的客服通话功能,当时第一反应是去找现成的第三方服务,报价单发过来一看,按分钟计费,量稍微大一点成本就很吓人。后来硬着头皮去啃 WebRTC,才发现这项技术本身早在十多年前就被主流浏览器原生支持了,只是入门门槛卡在几个概念上:SDP、ICE、STUN、TURN,一堆缩写砸下来,很容易让人中途放弃。

这篇内容面向的是零基础但有一定前端基础的朋友,我会把 WebRTC 从"它到底解决什么问题"开始拆,一直讲到你能自己跑通一个一对一通话的最小闭环,再把实际踩过的坑和排查手法摊开讲。WebRTC 全称是 Web Real-Time Communication,直译过来就是网页实时通信,它让浏览器不用装插件、不用装客户端,就能直接采集麦克风摄像头、建立点对点连接、传输音视频和数据。适合做的场景很具体:一对一视频客服、在线问诊、远程面试、在线教育小班课、屏幕共享、局域网内的文件直传,都能用得上。

我下面讲的顺序不是按教科书来的,而是按我当年真正搞懂它的顺序来的:先看骨架,再看零件,最后动手装。你照着走一遍,基本就能脱离"只会抄 Demo"的阶段。

1. 先把 WebRTC 的骨架拆开看清楚

1.1 它解决的其实是"两个浏览器怎么直接对话"

理解 WebRTC 最省力的方式,是先想清楚一件反直觉的事:两个浏览器要传音视频,中间隔着路由器、防火墙、运营商网络,它们互相根本不知道对方在哪。传统的做法是都往一台服务器上传,服务器再转发给对面,简单但成本高、延迟大。

WebRTC 的思路是让浏览器自己想办法找到对面,建立一条尽量直连的通道,也就是所谓的 P2P。这样媒体流不经过你的服务器,服务器只负责"介绍人"的角色,带宽成本几乎为零,延迟也低。

但这里有个死结:两边都不知道对方在哪,谁先开口?所以 WebRTC 的设计里,把"怎么找到对方"这件最麻烦的事,明确交给了应用开发者自己做,浏览器只提供工具不提供方案。这是 WebRTC 最容易被误解的一点,很多人以为引入这个库就自动能通话了,实际上你还得自己写一套信令交换逻辑。

我习惯把这个体系分成三条独立的通道来看,理解了这三条,整个技术栈就立起来了:

通道传输内容谁来负责是否加密
信令通道SDP、ICE 候选、控制指令开发者自己实现(WebSocket 等)看你怎么实现
媒体通道音视频流浏览器内部的 SRTP强制加密
数据通道任意二进制/文本RTCDataChannel强制加密

信令通道就是"介绍人"之间的对话,负责把双方的网络信息、编解码能力互相告知;媒体通道是真正的音视频;数据通道是后来加的,可以传文件、传游戏指令、传状态同步包。三条通道里只有前两条是基础,第三条属于加分项。

1.2 为什么标准里故意不定义信令协议

这是 WebRTC 设计上最值得玩味的一个取舍。W3C 和 IETF 的规范里,翻遍了也找不到"信令协议"的定义,不是遗漏,是刻意留白。

原因很现实:不同业务对信令的需求差异太大,有的应用需要鉴权、有的需要房间管理、有的需要离线消息、有的需要和已有的 IM 系统打通。如果标准里硬性规定一套,反而会限制住所有人。留白之后,你可以用 WebSocket,可以用长轮询,甚至可以在局域网里用二维码扫码交换信息,只要能完成交换就行。

代价就是新手会懵:教程里到处是RTCPeerConnection,但"谁来传 offer"这个问题没人回答。我当初卡在这里卡了两天,一直以为是自己漏了某个 API。

所以你在动手之前必须先想清楚信令层长什么样。最简单的一种是:用 WebSocket 建一个房间,两个人进同一个房间,先到的发 offer,后到的发 answer,ICE 候选随生成随发。这个极简模型能覆盖 90% 的入门学习需求,我后面第 3 节就是照这个搭的。

提示:信令服务器只做转发,不要在里面做任何编解码或者媒体处理,一旦你开始往信令层塞媒体,架构就已经歪了。

1.3 和传统方案的选型对比

在真实项目里,WebRTC 不是唯一选项,选错了后面全是返工。我整理过一张对比表,按我实际用下来的感受填的:

方案延迟量级服务端成本适合场景明显短板
WebRTC100ms 级极低(P2P)双向实时互动多人场景复杂、需要穿透
基于 TCP 的推流协议1-3s中单向直播互动延迟大
切片式流媒体5-30s低(可 CDN)点播、大并发观看不适合对话
普通 WebSocket 传音频包200ms 上下中高语音对讲、指令类无回声消除、无抖动缓冲

选型的判断标准其实就一条:是否需要双方实时互相响应。需要,就上 WebRTC;只是单向广播,切片式方案更省心。

我见过有团队拿 WebRTC 去做几千人同时在线的直播,结果连接数直接把服务器压垮,后来又换回切片方案,白白折腾两个月。多人场景不是不能做,而是架构要换成 SFU 中转,那是另一个量级的工作量。

2. 几个绕不开的核心概念,用生活场景讲透

2.1 媒体采集:getUserMedia 的参数不是随便填的

一切从拿到本地音视频开始。核心 API 就一个:

const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 }, facingMode: 'user' }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });

这里有几个细节必须说清楚。ideal和exact的区别很多人搞混:ideal是"尽量满足,满足不了降级",exact是"必须满足,否则直接抛错"。移动端摄像头能力参差不齐,写exact: 1280很可能在旧设备上直接失败,我一般全用ideal,然后在track.getSettings()里读实际拿到的分辨率做适配。

音频那三个开关,echoCancellation回音消除、noiseSuppression降噪、autoGainControl自动增益,默认值在各浏览器上不一致,我建议全部显式写出来。特别是做音乐类应用的时候,回音消除会把钢琴声削得很难听,这种场景要主动关掉。

还有一个坑:getUserMedia只能在安全上下文里调用,也就是 HTTPS 或者 localhost。你在局域网用http://192.168.x.x打开页面,API 直接不存在,会报navigator.mediaDevices is undefined。我第一次搭测试环境就栽在这,一度怀疑是浏览器版本问题,最后才发现是协议的事。

参数选定之后要考虑带宽。粗略估算,VP8 编码下 640×480@30fps 大约 500kbps 到 1Mbps,1280×720@30fps 大约 1.5Mbps 到 2.5Mbps,码率会随画面复杂度动态浮动。如果走 TURN 中继,服务器带宽要按双向算,也就是单路通话在中继上会占用接近 2 倍码率的流量。这个数字在做成本预算时很关键,很多人只算了并发数,忘了乘 2。

2.2 SDP:那个看起来很吓人的长文本到底是什么

第一次在控制台打印pc.localDescription.sdp,看到几十行文本,我整个人是懵的。其实把它当成分层描述就好,它就是在说三件事:我支持哪些编解码、我的网络地址是什么、我要传输哪几路媒体。

一个精简过的 SDP 长这样:

v=0 o=- 4611731400430051336 2 IN IP4 127.0.0.1 s=- t=0 0 m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 a=mid:0 a=sendrecv a=rtpmap:111 opus/48000/2 m=video 9 UDP/TLS/RTP/SAVPF 96 97 a=mid:1 a=sendrecv a=rtpmap:96 VP8/90000 a=rtpmap:97 H264/90000

看懂三个关键点就够用了。m=开头的行是媒体行,表示"我要传一路音频/视频",后面跟的端口和协议;a=rtpmap是编解码映射,声明支持 opus 音频、VP8 或 H264 视频;a=sendrecv表示双向收发。

协商的过程就是:发起方把本地 SDP 打包成 offer,接收方收到后生成 answer,answer 里只保留双方都支持的编解码交集。这个过程叫 SDP 协商,浏览器自动做,你通常不需要手改。

但我确实遇到过需要动手改的场景。有一次需求是把视频码率压到很低以适配弱网,直接改pc.localDescription.sdp里的b=AS:行,改成再setLocalDescription,确实生效了。这种做法属于 hack,稳定做法是后面要讲的RTCRtpSender.setParameters。

注意:SDP 里的 IP 地址部分在真实网络里往往是内网地址,对端根本连不上,真正起作用的是后面通过 ICE 收集到的候选地址。别盯着 SDP 里的 IP 去排查连通性问题。

2.3 ICE、STUN、TURN:三兄弟的分工必须分清

这三个词是新手最容易混的,我用一个找房子的类比来讲。

ICE是整体框架,全称 Interactive Connectivity Establishment,它做的事是:把所有可能的连接路径都收集起来,一个个试,哪个通就用哪个。它本身不是协议,是一套流程。

STUN是"照镜子"。你的设备在 NAT 后面,自己不知道对外的公网地址是什么。STUN 服务器的作用就是告诉你:你从外面看起来是1.2.3.4:54321。拿到这个地址,你把它写进候选列表里发给对方,对方有可能直接连上你。

TURN是"中转站"。当双方都在严格的 NAT 后面,STUN 拿到的地址怎么打都打不通时,就退化成走 TURN 服务器中转。所有流量都从服务器过,能连上,但有成本、有延迟。

ICE 收集候选的过程是并行的,同时去问 STUN 要公网地址、去问 TURN 要中继地址、以及本地网卡的地址。收集完就通过信令发给对方,双方两两配对尝试连通性检测。

候选类型有四种,看到typ字段就能分辨:

候选类型含义连通成功率成本
host本地网卡地址同局域网内高零
srflx经 STUN 反射的公网地址中零
prflx连接过程中发现的地址辅助零
relay经 TURN 中继的地址最高有带宽成本

实际生产中,TURN 是必须准备的兜底,别指望 P2P 一定能通。根据我的经验,在相对宽松的网络环境里 P2P 成功率大概八成到九成,在企业内网、移动蜂窝网络下会更低。没有 TURN 兜底,用户投诉"打不通"是迟早的事。

iceServers配置大概是这样:

const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.example.com:3478' }, { urls: 'turn:turn.example.com:3478', username: 'demo', credential: 'demo-pass' } ], iceTransportPolicy: 'all', iceCandidatePoolSize: 4 });

iceCandidatePoolSize是提前预收集候选池的大小,设为 4 可以缩短建连时间,代价是提前占用一点资源。iceTransportPolicy设成relay可以强制只走中继,我在测试 TURN 是否可用时经常这么干,能快速验证中继配置对不对。

2.4 安全这件事,WebRTC 帮你做了但你要知道

WebRTC 的所有媒体流强制加密,用的是 DTLS-SRTP,没有"关闭加密"这个选项。这点挺好,省了很多合规麻烦。但有两个副作用要清楚。

第一,加密是在浏览器内部完成的,你拿不到明文媒体流去二次处理,想做 AI 分析只能从中获取采样数据或者用其他方式。第二,加密过程依赖证书交换,RTCPeerConnection建立连接时会有一个 DTLS 握手阶段,如果两边时钟差异过大或者其他原因导致握手失败,表现就是连接建立成功但媒体不通,这种情况日志里会出现相关报错。

还有权限这一块,摄像头麦克风的授权是用户手动点的,getUserMedia一旦被拒绝,后续再调用会一直 reject,除非用户手动改权限。我要做的一个客服系统就遇到过:用户第一次手滑点了"拒绝",后面每次进来都失败,页面还显示"设备异常",投诉率很高。后来加了权限状态检测navigator.permissions.query({name: 'camera'}),被拒绝时弹引导文案教用户去地址栏左侧改权限,投诉才降下来。

3. 动手搭一个能跑通的一对一通话 Demo

3.1 最小可运行的环境准备

这一节我按顺序把每一步都写清楚,你照着敲能直接跑起来。需要的东西不多:一个支持 ES6 的现代浏览器、Node 环境、一个文本编辑器。

先建目录,装依赖。信令服务器我用 WebSocket 实现,选ws这个库是因为它够轻,不引入额外框架:

mkdir webrtc-demo && cd webrtc-demo npm init -y npm install ws mkdir public

目录结构最终长这样:

webrtc-demo/ ├── server.js # 信令服务器 ├── package.json └── public/ ├── index.html # 页面 └── client.js # 前端逻辑

我特意不引入打包工具,用原生 ES module 直接跑,减少一层干扰。零基础阶段最怕的就是还没搞懂原理就被构建配置劝退。

本地测试时,localhost属于安全上下文,可以直接调摄像头,不需要配 HTTPS。如果你想让手机也连进来测试,那就得用 HTTPS 了,这个后面排查章节再说。

3.2 信令服务器:只做转发,别做多余的事

服务器的职责只有三件:分配房间、通知对端加入、转发消息。核心代码不到四十行:

const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); const rooms = new Map(); wss.on('connection', ws => { let currentRoom = null; ws.on('message', raw => { const msg = JSON.parse(raw); if (msg.type === 'join') { currentRoom = msg.room; if (!rooms.has(currentRoom)) rooms.set(currentRoom, new Set()); const peers = rooms.get(currentRoom); // 通知已有成员:有新人来了 peers.forEach(p => p.send(JSON.stringify({ type: 'peer-joined' }))); peers.add(ws); ws.send(JSON.stringify({ type: 'joined', count: peers.size })); return; } // offer / answer / candidate 一律广播给房间内其他人 const peers = rooms.get(currentRoom) || new Set(); peers.forEach(p => { if (p !== ws && p.readyState === WebSocket.OPEN) { p.send(raw.toString()); } }); }); ws.on('close', () => { const peers = rooms.get(currentRoom); if (peers) { peers.delete(ws); peers.forEach(p => p.send(JSON.stringify({ type: 'peer-left' }))); } }); }); console.log('signaling server on ws://localhost:8080');

这里有个设计细节值得说:我加了一个peer-joined通知。为什么需要?如果两个人同时进入房间,双方都不知道该谁发 offer,就会出现互相等的情况,或者双双发 offer 导致协商冲突。

我的处理方式是约定"房间里已有成员时,新加入的一方主动发起 offer"。peer-joined让老成员知道有人来了,新成员收到joined且count > 1时触发发起流程。这套规则简单但能避免绝大多数时序问题。

提示:这个服务器没有任何鉴权和房间人数限制,只适合本地学习。上生产必须加连接数上限、心跳检测、消息体大小限制,否则很容易被刷。

3.3 采集本地流并做本地预览

页面部分我保持朴素,两个 video 标签,一个放自己一个放对方:

<video id="localVideo" autoplay playsinline muted></video> <video id="remoteVideo" autoplay playsinline></video> <button id="joinBtn">加入房间</button>

playsinline属性在移动端必须加,不加会强制全屏播放;本地视频要加muted,不加会自己听到自己的回声,形成啸叫。这两个属性我吃过亏,第一次在手机上测试,页面一进来就全屏了,排查了半天以为是样式问题。

采集部分:

async function getLocalStream() { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: { echoCancellation: true, noiseSuppression: true } }); document.getElementById('localVideo').srcObject = stream; return stream; }

注意是赋值给srcObject而不是src,src只接受 URL。这个细节新手经常搞错,写成src = stream之后视频黑着但没报错,很难查。

拿到流之后,最容易被忽略的一步是:必须把轨道加进 peer connection。

stream.getTracks().forEach(track => pc.addTrack(track, stream));

我有一次漏了这行,SDP 里m=行全是recvonly,对方什么都收不到,日志看起来一切正常,就是没画面。

3.4 建立连接:offer / answer 的完整时序

这是整个流程的核心,我把完整时序按代码顺序写出来。先创建连接对象并挂事件:

const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.example.com:3478' }, { urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p' } ] }); // 对端轨道到达 pc.ontrack = event => { document.getElementById('remoteVideo').srcObject = event.streams[0]; }; // ICE 候选产生,立刻通过信令发出去 pc.onicecandidate = event => { if (event.candidate) { send({ type: 'candidate', candidate: event.candidate }); } }; // 连接状态监控,排查时全靠它 pc.onconnectionstatechange = () => { console.log('connectionState:', pc.connectionState); }; pc.oniceconnectionstatechange = () => { console.log('iceConnectionState:', pc.iceConnectionState); };

发起方:

async function createOffer() { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); send({ type: 'offer', sdp: pc.localDescription }); }

接收方收到 offer 后:

async function handleOffer(sdp) { await pc.setRemoteDescription(new RTCSessionDescription(sdp)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); send({ type: 'answer', sdp: pc.localDescription }); }

发起方收到 answer 后:

async function handleAnswer(sdp) { await pc.setRemoteDescription(new RTCSessionDescription(sdp)); }

候选处理两边一样:

async function handleCandidate(candidate) { if (pc.remoteDescription) { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } else { // 远端描述还没设,先缓存 pendingCandidates.push(candidate); } }

最后那段缓存逻辑一定要加。ICE 候选可能比 answer 先到,这时候addIceCandidate会因为remoteDescription为空而抛错。我在第一次实现时没加缓存,出现了"有时能通有时不能"的玄学问题,追查了很久才发现是消息乱序。

3.5 中继服务这一块怎么准备

STUN 和 TURN 我建议自己搭一个,本地测试用开源的 TURN 实现跑在一台有公网地址的机器上即可,配置里重点关注的字段有几个:

配置项作用建议值
监听端口UDP/TCP 收流3478
中继端口范围分配给客户端的端口段49152-65535
认证方式防止被白嫖用户名密码或临时凭证
外部地址服务器公网 IP必填,否则候选地址错

external-ip这个字段如果不填,服务端收集到的候选地址会是内网地址,客户端拿到之后怎么连都连不上。这一条几乎是所有自建 TURN 的人必踩的第一个坑。

临时凭证的做法更安全一些,服务端按约定的算法生成带时效的用户名和密码下发给前端,避免长期密钥泄露被滥用。入门阶段先用固定账号跑通,能通了再换成临时凭证。

4. 实际会踩的坑和排查手法

4.1 黑屏、单通、无声,按这个顺序查

这三类问题占了初学者遇到问题的八成以上,我把排查顺序固定下来,基本能在五分钟内定位。

第一步看connectionState。如果是failed,说明连接压根没建立,方向在 ICE 和信令上;如果是connected但画面不动,方向在媒体轨道和编解码上。

第二步看pc.getSenders()和pc.getReceivers()。发送方列表为空,说明你忘了addTrack;有发送方但getStats()里outbound-rtp的bytesSent一直是 0,说明轨道在但没出数据,通常是track.enabled = false或者设备被占用。

第三步看对端的 SDP。recvonly还是sendrecv,一眼就能判断是没加轨道还是协商没做对。

我整理了一张速查表,遇到问题可以直接对:

现象最可能原因快速验证
拿到流但本地黑屏用了 src 而非 srcObject检查赋值语句
本地有画面远端黑屏漏了 addTrack打印 getSenders()
远端有画面没声音音频轨道被 muted 或设备冲突检查 track.enabled
connectionState 卡在 connecting候选没送达或 TURN 不可用打印收到的候选数量
立刻变 failed信令格式错或 SDP 未设置检查 setRemoteDescription 是否 await
通话十秒后断开无心跳,信令连接被回收加定时 ping
移动端全屏无法内嵌缺 playsinline补属性

getStats()是我最依赖的诊断工具,声音小、卡顿、丢包全在里面:

const stats = await pc.getStats(); stats.forEach(report => { if (report.type === 'inbound-rtp' && report.kind === 'video') { console.log('丢包', report.packetsLost, '抖动', report.jitter); } });

看packetsLost和jitter就能判断是网络问题还是编码问题。丢包持续增长,说明链路质量差;抖动大但丢包少,说明需要调整抖动缓冲。

4.2 局域网能通、跨网就不通,问题在哪

这个现象我遇到过至少三次,每次根因都不一样,但排查路径是固定的。

先确认候选有没有收集全。把pc.onicecandidate里收到的候选打印出来,正常情况下应该有 host 和 srflx 两类。如果只有 host,说明 STUN 服务器不通,检查地址和端口;如果连 host 都没有,那问题在采集和协商,跟网络无关。

再确认候选有没有送到对端。很多人只打印了自己发出的候选,没打印收到的,结果是中间信令通道丢了消息自己不知道。我现在的习惯是在信令服务器上加日志,进出都记一条,两边一对比就知道丢在哪。

最后确认 TURN 能不能用。临时把iceTransportPolicy设成relay强制走中继,如果这样能通,说明 P2P 失败了但兜底正常,问题在 NAT 穿透,这种属于正常情况,不用纠结;如果强制中继也不通,那就是 TURN 配置问题,重点查external-ip和端口开放情况。

4.3 体验层面的优化,别等用户投诉才做

功能跑通和能用之间差着一段距离。我总结了几个投入产出比最高的优化点。

码率自适应。通过setParameters动态调整:

const sender = pc.getSenders().find(s => s.track.kind === 'video'); const params = sender.getParameters(); params.encodings[0].maxBitrate = 800000; // 800kbps params.encodings[0].maxFramerate = 20; await sender.setParameters(params);

弱网下把码率压下来,画面会糊但不卡,体验远好于精细但一直转圈。

关闭不必要的轨道。音频比视频重要得多,网络紧张时优先保音频。我的做法是检测到丢包率超过阈值,临时把视频轨道的enabled设 false,等网络恢复再打开,用户能接受画面暂停,但接受不了声音断续。

处理设备切换。用户中途插拔耳机、切换摄像头,navigator.mediaDevices.ondevicechange能监听到。切换摄像头要用RTCRtpSender.replaceTrack()替换轨道,而不是重新协商,后者会中断通话。这个方法我用了很久才发现,之前每次切换都断一下,用户体验很差。

移动端后台。手机浏览器切到后台,页面可能被挂起,媒体流被系统回收。这种场景要做的是监听visibilitychange,回来时检查轨道状态,必要时重建连接。这块没有通用解,不同设备和浏览器的表现差异很大,只能在目标机型上逐个测。

5. 从能跑到能用,还差哪几块

5.1 多人场景的架构选择

一对一跑通之后,下一步往往是多人。这里必须提前认清一件事:WebRTC 的 P2P 模型不适合多人,原因是每个参与者都要和其他所有人建立一条连接,N 个人就是 N×(N-1)/2 条,上行带宽随人数线性增长。

三个人的时候能忍,五个人的时候手机就开始发烫,十个人基本就跑不动了。所以多人必须换架构:

架构上行连接数服务器压力适合人数
全网状N-1无2-4 人
服务端转发1高(带宽)几十人
选择性转发1中(CPU)十几到上百

选择性转发的思路是服务器接收每路上行,按需转发给其他参与者,并且可以根据订阅关系、分辨率层级做差异化分发。这是目前主流做法,但它已经不是 WebRTC 本身能解决的了,需要引入媒体服务器。

我的建议很直接:三人以下用 P2P,超过就上媒体服务器,别在 P2P 上硬撑。中间那些"优化 P2P"的方案,投入产出比都很差。

5.2 几个容易被低估的工程细节

自动播放策略。主流浏览器都限制了有声自动播放,video.play()可能返回 rejected。做法是把播放动作绑定到用户点击事件里,或者在播放失败时显示一个"点击开始"的遮罩。这个坑在移动端几乎必踩。

多标签页冲突。同一个浏览器开两个标签页测试,摄像头有时只能被一个占用,表现为其中一个标签页拿不到流。测试时务必用两个不同的浏览器,或者一个正常窗口一个隐私窗口。

网络切换重连。手机从 WiFi 切到蜂窝网络,原来的连接会断。要做的是监听iceconnectionstatechange,转为disconnected时先等待几秒,如果能自愈就不用管,超过时间就触发重新协商。直接重建连接会让通话明显中断,更好的做法是发起 ICE 重启:

pc.oniceconnectionstatechange = async () => { if (pc.iceConnectionState === 'failed') { const offer = await pc.createOffer({ iceRestart: true }); await pc.setLocalDescription(offer); send({ type: 'offer', sdp: pc.localDescription }); } };

iceRestart: true这个参数很关键,不带这个参数重新 createOffer 拿到的候选还是旧的,等于白做。

日志与监控。生产环境一定要把getStats()的关键指标定期上报:连接状态、往返时延、丢包率、可用带宽。我做过一次线上问题追查,没有这些数据完全是靠猜,有了之后十分钟就定位到是某地区的中继节点带宽打满了。入门阶段不需要做这么细,但要知道这个方向存在,等真出问题的时候不会手忙脚乱。

最后分享一个我个人一直保留的习惯:每接一个新的实时音视频需求,我会先花半天时间,在目标设备上把"能不能建连、能不能通、中继通不通"这三件事单独验证一遍,再开始写业务代码。这个前置检查看着浪费时间,实际上省掉的是后面几天对着黑屏猜原因的工夫。WebRTC 的问题绝大多数不在代码逻辑里,而在网络和设备环境里,先摸清环境底细,比写多少行优雅的代码都有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询