WebRTC 这个词第一次出现在需求文档里的时候,很多人的第一反应是去搜“有没有现成的 SDK”,第二反应是“装什么依赖”。等你真拆开看会发现,这东西一个包都不用装——它就在浏览器里,是浏览器自带的一套音视频管道。你写几十行 JavaScript,两台设备就能直接对上,音视频流不经过你的服务器,延迟能压到几十毫秒。听起来像白捡的便宜,但真正动手的人很快会撞上三道墙:信令得自己搭、NAT 穿透不是每次都成功、上线之后本地能通线上黑屏。这篇东西是写给完全没碰过 WebRTC、但要在一两周内把它跑起来的人看的,从“它到底是什么”讲到“上线前该检查哪几项”,中间那些我踩过的坑、参数为什么这么填、代码为什么这么写,都会说清楚。看完你至少能做到:跑通一个点对点的音视频通话 Demo,并且知道它为什么能通、什么时候会不通。
1. 先把 WebRTC 拆开看:它其实是浏览器自带的一条音视频流水线
1.1 三个核心对象撑起整条链路
很多人把 WebRTC 当成一个“库”,这个理解一开始就会带你走偏。它更像浏览器内置的一条流水线,入口是设备,出口是对方的屏幕,中间有三个关键对象在传递数据。
第一个是MediaStream,代表一路媒体流。你要拿到它,得调navigator.mediaDevices.getUserMedia()或getDisplayMedia()。这一步干的事情是向用户申请权限,然后从摄像头、麦克风或者屏幕抓数据。抓到的MediaStream你可以直接塞给<video>标签播放,此时它还完全是本地的,和网络没半点关系。
第二个是RTCPeerConnection,整条流水线的核心。它的职责是:把本地的MediaStream里的轨道(track)挂上去,然后和远端协商出一套双方都能接受的编码格式、分辨率、码率,最后把数据打包成 RTP 包发出去。注意,它只管“发”,不管“发给谁”——找对方这件事不在它的能力范围里。
第三个是信令通道,WebRTC 标准里压根没有它。这是新手最容易懵的地方:官方文档写得明明白白,但你就是找不到“连接对方”的 API。原因很简单,标准把这一层故意留白了,你可以用 WebSocket、可以用 HTTP 轮询、甚至可以用飞鸽传书把 SDP 文本念给对方听,只要能交换文本就行。
提示:如果你在教程里看到“WebRTC 服务器”,八成指的是信令服务器或者 TURN 中继服务器,而不是媒体服务器。媒体流正常情况下是两端直连的,你自己的服务器一根字节都不会经手。
1.2 媒体流不走服务器,那它凭什么能找到对方
这是 WebRTC 最反直觉的一点。传统的直播或通话方案,你的画面先推流到服务器,服务器再分发给观看方,路径是“你 → 服务器 → 对方”。WebRTC 想做的是“你 → 对方”,中间不打转。
问题来了:绝大多数设备都在 NAT 后面,没有公网 IP,外面的设备根本不知道怎么敲你的门。WebRTC 的解法是 ICE(Interactive Connectivity Establishment),一套“把能想到的所有路径都试一遍”的机制。它会收集本机的局域网地址、通过 STUN 服务器问出来的公网映射地址、以及 TURN 中继服务器提供的转发地址,凑成一堆候选(candidate),全部丢给对方,双方同时往这些地址发包,谁先通就用谁。
这套机制的好处是绝大多数情况下能直连成功,延迟低、服务器成本几乎为零。坏处是它不保证成功——在对称型 NAT 或者严格的防火墙环境下,直连就是不通,只能退回 TURN 中继,此时媒体流确实过了你的服务器,带宽成本立刻变成真金白银。
1.3 它不负责的那些事,才是你真正的工作量
刚接触的人常有的错觉是“WebRTC 能实现视频通话”,然后按这个预期估工期。实际上一半以上的工作量在 WebRTC 之外。
它不负责用户怎么找到对方——房间号、邀请链接、在线列表、鉴权,这些全是你的事。它不负责多方通话的混流——三个人以上直接全部 P2P 会让上行带宽爆炸,通常需要一个 SFU(选择性转发单元)来中转,这就是另一个技术栈了。它不负责录制、转码、内容审核、通话质量报表。它甚至不负责在通话结束时清理资源,close()得你自己调。
我在某项目里见过一个估算失误的案例:某团队按“接个 WebRTC 就完事”排了两周,结果光是信令协议和断线重连就吃掉十天,真正和 WebRTC API 打交道的时间不到三天。所以一开始就把边界划清楚,比急着写代码重要得多。
2. 信令层为什么必须自己搭:标准故意留白的那一块
2.1 标准只管“两端怎么谈”,不管“两端怎么找到彼此”
WebRTC 的协商过程本质上是一次双向的文本交换。发起方生成一个 SDP(Session Description Protocol)格式的 offer,描述“我支持这些编码、我打算发这些轨道、我的网络候选是这些”;接收方收到后回一个 answer,说明“我接受哪些、我拒绝哪些”。这个 exchange 必须通过某条通道完成,而标准只规定了 SDP 的格式,没规定这条通道长什么样。
这么设计的理由其实很务实:不同的业务场景对信令的要求天差地别。一个一对一的客服系统,可能只需要简单的房间广播;一个大型会议系统,需要鉴权、需要区分主持人权限、需要服务端主动踢人。如果标准把信令协议写死,所有人都得迁就它,反而更难用。留白之后,你用现有的一套 WebSocket 服务就能接上,灵活度最高。
代价就是每个新手都得自己写一遍。好消息是,最小可用的版本非常短。
2.2 一个最小可用的信令服务
我用 Node.js 加ws库写了一份,四十行左右,够跑通一对一。核心逻辑就三件事:维护房间到连接的映射、转发消息、连接断开时通知对面。
// signal-server.js const { WebSocketServer } = require('ws'); const rooms = new Map(); // roomId -> Set<ws> const wss = new WebSocketServer({ port: 8080 }); wss.on('connection', (ws) => { let currentRoom = null; let peerId = null; ws.on('message', (raw) => { let msg; try { msg = JSON.parse(raw); } catch (e) { return; // 非法消息直接丢弃,别让脏数据进业务 } if (msg.type === 'join') { currentRoom = msg.roomId; peerId = msg.peerId; if (!rooms.has(currentRoom)) rooms.set(currentRoom, new Set()); const peers = rooms.get(currentRoom); // 房间里已经有一个人,告诉他"来新人了",让对方先发 offer if (peers.size >= 1) { for (const other of peers) { other.send(JSON.stringify({ type: 'peer-joined', peerId })); } } peers.add(ws); return; } // offer / answer / candidate 一律原样转发给房间里的其他人 const peers = rooms.get(currentRoom); if (!peers) return; for (const other of peers) { if (other !== ws && other.readyState === 1) { other.send(JSON.stringify({ ...msg, from: peerId })); } } }); ws.on('close', () => { const peers = rooms.get(currentRoom); if (!peers) return; peers.delete(ws); for (const other of peers) { other.send(JSON.stringify({ type: 'peer-left', peerId })); } if (peers.size === 0) rooms.delete(currentRoom); }); });这份代码刻意做得很粗糙,没有心跳、没有断线重连、没有鉴权,但它能让你先把链路跑通。跑通之后再往里加东西,比一开始就想做个生产级信令服务要现实得多——我见过太多人卡在“设计一个完美的信令协议”上,两周过去代码还没跑起来。
2.3 消息格式的约定,别用裸字符串
上面代码里我用了一个统一的 JSON 结构,type字段区分消息类型,from字段标记来源。这个约定看起来不起眼,但省了我很多事。
至少要定义这几类消息:join(加入房间)、peer-joined(有人进来)、peer-left(有人离开)、offer、answer、candidate、bye。前三类是业务层的,后四类是 WebRTC 协商需要的。把 SDP 和 candidate 原样放在payload字段里转发,服务端不需要理解内容。
这里有个我踩过的坑:一开始我把candidate消息用了广播,结果三人房间里各种候选乱飞,连接建立极慢。原因很简单,候选只对特定的那条连接有意义,广播出去会让对方的addIceCandidate收到不匹配的候选,虽然通常会被内部丢弃,但会白白消耗处理时间。一对一场景广播没事,多方场景必须带目标 ID。
3. 从建立连接到看到画面:一次完整握手的逐行拆解
3.1 采集:getUserMedia 的约束参数怎么写才不坑
getUserMedia的约束对象有两种写法,{ video: true, audio: true }是最简单的,但拿到的分辨率完全看设备心情,可能是 640×480,也可能是 1280×720,在不同机器上体验不一致。
想控制的话用ideal和exact两个关键字。ideal是“尽量满足,满足不了就凑合”,exact是“必须满足,否则直接报错”。新手最容易犯的错是把分辨率写成exact: 1920,然后在一台只支持 720p 的摄像头设备上直接抛OverconstrainedError,页面一片空白。
async function getLocalStream() { const constraints = { audio: { echoCancellation: true, // 回声消除,不带耳机的场景必开 noiseSuppression: true, // 降噪,键盘声、风扇声靠它 autoGainControl: true, // 自动增益,离麦克风远近音量差不多 sampleRate: 48000 // 48k 是 WebRTC 的默认采样率,别乱改 }, video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 24, max: 30 }, facingMode: 'user' // 移动端前置摄像头 } }; const stream = await navigator.mediaDevices.getUserMedia(constraints); document.getElementById('localVideo').srcObject = stream; return stream; }echoCancellation、noiseSuppression、autoGainControl这三项默认值在各浏览器上并不统一,我习惯显式写死。尤其是echoCancellation,用户不戴耳机的时候,不开这个就是灾难现场——对方的每一句话都会被自己的麦克风再收一遍,形成刺耳的回授。
帧率我一般设 24 或者 25,不设 30。原因在码率上:帧率越高,同样的画质需要的码率越高,而屏幕共享、弱网场景下码率是稀缺资源。24 帧肉眼几乎看不出和 30 帧的区别,但能省下将近两成的带宽。
3.2 创建连接与挂载轨道
new RTCPeerConnection()这一步本身不产生任何网络请求,它只是创建了一个对象。真正关键的是构造参数里的iceServers。
const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.your-domain.com:3478' }, { urls: ['turn:turn.your-domain.com:3478?transport=udp', 'turn:turn.your-domain.com:3478?transport=tcp'], username: 'temporary-user', credential: 'temporary-pass' } ], iceCandidatePoolSize: 2, // 预收集候选,能让连接建立快 100~300ms bundlePolicy: 'max-bundle' // 音视频复用同一个传输通道 });iceCandidatePoolSize这个参数容易被忽略。设成 0(默认)时,候选收集是在setLocalDescription之后才开始的,中间白白等了一两百毫秒。设成大于 0,浏览器会提前开始收集,用户点“呼叫”的瞬间就有候选可用。听起来不多,但在实时通话的产品里,两百毫秒的感知差异是明显的。
bundlePolicy: 'max-bundle'让音频和视频走同一个 ICE 通道,减少端口占用和候选交换量。这个配置基本没有坏处,建议默认带上。
挂载轨道用addTrack:
localStream.getTracks().forEach((track) => { pc.addTrack(track, localStream); });注意addTrack的第二个参数streams不能省。某些场景下省了之后,远端的ontrack事件里event.streams会是空数组,你得手动把 track 拼成流再塞给 video 元素,很容易漏掉。
3.3 offer/answer 与 ICE 候选的交换顺序
这是最容易写错的地方,也是我调试最久的一块。正确顺序分成两条线,两条线是并行跑的。
第一条线是 SDP 协商:发起方createOffer→setLocalDescription→ 把 offer 发出去;接收方收到后setRemoteDescription(offer)→createAnswer→setLocalDescription(answer)→ 把 answer 发回去;发起方收到 answer 后setRemoteDescription(answer)。
第二条线是 ICE 候选:双方在setLocalDescription之后开始收集候选,每收到一个就通过onicecandidate回调抛出来,直接通过信令发给对方,对方收到后调addIceCandidate。
坑就出在顺序上:ICE 候选往往会比 SDP 先到。如果对方在setRemoteDescription之前就调用了addIceCandidate,浏览器会抛一个错误,候选直接丢失,连接可能因此建不起来。解决办法是缓存:
let pendingCandidates = []; pc.onicecandidate = (event) => { if (event.candidate) { signaling.send({ type: 'candidate', payload: event.candidate }); } else { // candidate 为 null 表示收集完成,常用于日志和状态判断 console.log('ICE gathering finished'); } }; async function handleRemoteCandidate(candidate) { if (!pc.remoteDescription) { pendingCandidates.push(candidate); // 还没设远端描述,先存着 return; } await pc.addIceCandidate(candidate); } async function handleRemoteDescription(desc) { await pc.setRemoteDescription(desc); // 设置完成后,把缓存的候选全部补进去 for (const c of pendingCandidates) { await pc.addIceCandidate(c); } pendingCandidates = []; }这段逻辑看着琐碎,但它是“本地能通、换台机器就不通”这类玄学问题的头号原因。我建议你一开始就把这个缓存逻辑写进去,别等到出问题再回头找。
3.4 一份能直接跑的最小双端代码
把上面的片段拼起来,再做点收尾,就能得到一个完整的页面。两个浏览器标签分别打开,一个点“加入”,另一个也点“加入”,通话就通了。
class PeerCall { constructor(signaling, roomId) { this.signaling = signaling; this.roomId = roomId; this.pendingCandidates = []; this.localStream = null; this.pc = null; } async start() { this.localStream = await getLocalStream(); this.pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.your-domain.com:3478' }] }); this.localStream.getTracks().forEach((t) => { this.pc.addTrack(t, this.localStream); }); // 远端轨道到达 this.pc.ontrack = (event) => { const remoteVideo = document.getElementById('remoteVideo'); if (remoteVideo.srcObject !== event.streams[0]) { remoteVideo.srcObject = event.streams[0]; } }; this.pc.onicecandidate = (event) => { if (event.candidate) { this.signaling.send({ type: 'candidate', payload: event.candidate }); } }; // 连接状态变化,排查问题的第一手信息来源 this.pc.onconnectionstatechange = () => { console.log('connectionState:', this.pc.connectionState); if (this.pc.connectionState === 'failed') { this.restartIce(); } }; this.signaling.on('peer-joined', () => this.createOffer()); this.signaling.on('offer', (msg) => this.handleOffer(msg.payload)); this.signaling.on('answer', (msg) => this.pc.setRemoteDescription(msg.payload)); this.signaling.on('candidate', (msg) => this.handleRemoteCandidate(msg.payload)); } async createOffer() { const offer = await this.pc.createOffer(); await this.pc.setLocalDescription(offer); this.signaling.send({ type: 'offer', payload: offer }); } async handleOffer(desc) { await this.pc.setRemoteDescription(desc); for (const c of this.pendingCandidates) await this.pc.addIceCandidate(c); this.pendingCandidates = []; const answer = await this.pc.createAnswer(); await this.pc.setLocalDescription(answer); this.signaling.send({ type: 'answer', payload: answer }); } async handleRemoteCandidate(candidate) { if (!this.pc.remoteDescription) { this.pendingCandidates.push(candidate); return; } await this.pc.addIceCandidate(candidate); } restartIce() { if (!this.pc || this.pc.signalingState === 'closed') return; this.pc.restartIce(); this.createOffer(); // 重新协商,既不换连接对象,也不需要重建轨道 } }restartIce这个方法值得单独说一句。弱网环境下连接偶尔会断,最粗暴的做法是关掉整个RTCPeerConnection重建一遍,但那样本地流要重新挂、媒体要重新协商,中断时间以秒计。restartIce()只重新收集候选、重新协商传输层,音视频轨道保持不变,恢复时间通常在两秒以内。我给某项目加上这个逻辑之后,“通话中途断开需要重拨”的投诉降了七成。
4. 卡在“本地通、上线黑屏”:NAT 穿透与 ICE 的那些坑
4.1 三种候选类型分别意味着什么
排查连线问题的第一步永远是打开pc.localDescription和pc.getStats(),看看双方到底交换了哪些候选。候选按前缀分三类:
| 候选类型 | 前缀 | 含义 | 连接是否直连 | 成本 |
|---|---|---|---|---|
| 主机候选 | host | 本机网卡地址,如 192.168.1.20 | 同一局域网内可直连 | 无 |
| 服务端反射候选 | srflx | 经 STUN 探测出的公网映射地址 | 大多数家用网络可直连 | 极低 |
| 中继候选 | relay | TURN 服务器分配的转发地址 | 永远能连上 | 按流量计费 |
只在本地测试的话,你看到的几乎全是host候选,因为两个标签页在同一台机器上,走回环地址就通了。这就解释了那个经典现象:开发机上一切完美,部署到线上让两地同事测试,黑屏。
relay候选的数量是最直观的告警指标。如果你在pc.localDescription.sdp里一个typ relay都看不到,说明 TURN 服务器压根没配好,或者凭据错了。等到真正遇到严格 NAT 的用户,必然连不上。
4.2 STUN 只负责告诉你“你在外面长什么样”
STUN 干的事极其简单:你给它发个请求,它告诉你“我看到你的源地址是 1.2.3.4:5678”。就这么一条信息,没有它你根本不知道自己在公网上的映射是什么,也谈不上让别人来找你。
它的局限在于,对于对称型 NAT,你发给 STUN 服务器时映射出来的端口,和你发给对方时映射出来的端口是不一样的,STUN 报告的那个地址对方连不上。家庭宽带里这种情况不算多数,但企业网络、某些运营商网络里很常见。
公共 STUN 服务可以用,但别依赖。生产环境我建议自己部署一个,或者至少配两个不同来源的,iceServers数组里可以放多个 STUN 地址,浏览器会并行探测。
4.3 TURN 是兜底,也是账单大头
TURN 服务器的工作是纯转发:你把包发给它,它转给对方。所有媒体流都过它一遍,带宽消耗按双向算。
粗略算一下成本。一路 720p 视频按 1.5Mbps 算,音频按 40kbps 算,单向约 1.54Mbps。中继是双向的,服务器实际出入各一份,所以一路通话对服务器的带宽消耗约为 3.1Mbps。一小时下来是 3.1 × 3600 ÷ 8 ≈ 1.4GB。如果是一对一,两个人就是两路,约 2.8GB/小时。这个数字乘以并发数和通话时长,很快就能算出为什么“免费视频通话”这件事做不成。
所以策略上要分两层:优先保证直连成功率,把 TURN 用量压到最低;同时设置每个会话的码率上限,避免某个用户开了 4K 摄像头把带宽吃光。
压直连成功率的手段有几个。一是多配几个不同网段的 STUN,提高srflx候选的命中率。二是把iceTransportPolicy设成'all'(默认值,别设成'relay',那会强制所有流量走中继)。三是给iceCandidatePoolSize一个非零值,让候选收集提前开始,减少“等候选等到超时”的概率。
4.4 排查链路:从状态字段到统计报表
我的排查顺序是固定的,从最外层往里收。
第一看pc.iceConnectionState。new是还没开始,checking是在试候选,connected或completed是通了,failed是试完了全失败,disconnected是曾经通过但现在断了(可能只是暂时的,给它几秒钟)。卡在checking超过十秒,基本可以确定是候选问题。
第二看候选列表。浏览器内置了一个 WebRTC 调试页面,地址在浏览器里可以查到(各家的入口不同,通常在开发者工具的某个面板里,或者输入一个特定的内部地址)。这个页面能看到完整的 SDP、所有收发的候选、码率曲线、丢包率、抖动。它是排查问题的第一手材料,比console.log强太多。
第三看pc.getStats()。这个 API 返回一大堆统计对象,我用得最多的是candidate-pair类型里的currentRoundTripTime(往返延迟,单位秒),以及inbound-rtp里的packetsLost和jitter。延迟超过 300ms 用户体验就开始明显变差,丢包率超过 5% 画面会开始花。
async function dumpStats(pc) { const stats = await pc.getStats(); stats.forEach((report) => { if (report.type === 'candidate-pair' && report.state === 'succeeded') { console.log('RTT:', (report.currentRoundTripTime * 1000).toFixed(0), 'ms'); console.log('可用带宽估算:', report.availableOutgoingBitrate); } if (report.type === 'inbound-rtp' && report.kind === 'video') { console.log('丢包:', report.packetsLost, '抖动:', report.jitter); } }); }availableOutgoingBitrate这个字段很有用,它是浏览器根据网络状况估算出的可用上行带宽。你可以用它做动态降级:低于 500kbps 就主动调低分辨率,而不是等浏览器自己扛不住开始掉帧。
5. 除了打电话:数据通道、屏幕共享与弱网降级
5.1 RTCDataChannel:把浏览器当 socket 用
RTCDataChannel是 WebRTC 里被严重低估的一块。连接建好之后,你可以在同一条通道上开一条双向的数据通道,传任意二进制数据。它的优势是延迟极低(走的也是 UDP,配合 SCTP 协议),而且和媒体流共享同一条 ICE 通道,不需要额外的服务器。
创建方式很简单,发起方调createDataChannel,接收方监听ondatachannel:
// 发起方 const dc = pc.createDataChannel('chat', { ordered: false, // 不保证顺序,追求低延迟 maxRetransmits: 0 // 不重传,丢了就丢了 }); dc.onopen = () => console.log('channel open'); dc.onmessage = (e) => console.log('收到:', e.data); // 接收方 pc.ondatachannel = (event) => { const dc = event.channel; dc.onmessage = (e) => console.log('收到:', e.data); };ordered和maxRetransmits这两个参数决定了通道的性格,用错了会很难受。传文件、传聊天消息,必须ordered: true且不设重传上限(默认是可靠传输);传实时位置、传游戏操作、传光标坐标,就该设成ordered: false加maxRetransmits: 0,因为一条过期的位置信息毫无价值,重传反而增加延迟。
注意:
maxRetransmits和maxPacketLifeTime不能同时设置,二选一。前者限制重传次数,后者限制消息的有效期。实时场景我一般用maxPacketLifeTime: 200,超过 200ms 的消息直接丢弃。
5.2 屏幕共享:getDisplayMedia 与“切窗口后画面糊了”
屏幕共享用getDisplayMedia,和getUserMedia的区别是它必须由用户手势触发(点击事件里调用),不能在页面加载时自动调。这个限制是刻意的,防止页面偷偷录屏。
async function shareScreen() { const stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: 15 }, // 共享屏幕 15 帧足够,省带宽 width: { max: 1920 } }, audio: false // 系统音频采集兼容性差,谨慎开启 }); const videoTrack = stream.getVideoTracks()[0]; // 关键:告诉编码器这是"细节优先"还是"运动优先" videoTrack.contentHint = 'detail'; return stream; }contentHint这个属性是屏幕共享场景的救命稻草。默认情况下编码器会按“视频会议”的策略优化,优先保证运动流畅度,结果就是你共享一个静态的代码窗口时,画面被压得字都看不清。设成'detail'之后,编码器会优先保留空间细节,文字变清晰;共享视频播放或者游戏画面时,设成'motion'更合适。
另一个常见问题是用户共享了 A 窗口,然后切到 B 窗口,画面突然变糊或者比例失调。原因是被共享窗口的尺寸变了,而视频轨道还在按旧的尺寸编码。处理办法是监听轨道的变化:
videoTrack.onmute = () => console.log('轨道暂停,可能用户最小化了窗口'); videoTrack.onunmute = () => console.log('轨道恢复'); // 部分浏览器支持 configurationchange 事件 videoTrack.addEventListener('configurationchanged', () => { const settings = videoTrack.getSettings(); console.log('分辨率变化:', settings.width, settings.height); });实际项目里,我倾向于在检测到尺寸变化时主动调一次applyConstraints,把宽高重新约束到合理范围,比依赖浏览器的自动适配更稳。
5.3 弱网降级:把控制权拿回来
浏览器自己有带宽估计和自适应逻辑,但它偏保守,而且策略不可控。想要更精细地控制画质,得用RTCRtpSender.setParameters手动设码率上限。
async function setMaxBitrate(pc, kbps) { const senders = pc.getSenders(); for (const sender of senders) { if (!sender.track || sender.track.kind !== 'video') continue; const params = sender.getParameters(); if (!params.encodings || params.encodings.length === 0) { // 某些浏览器在 addTrack 后 encodings 是空的,需要先赋值一个空对象 params.encodings = [{}]; } params.encodings[0].maxBitrate = kbps * 1000; await sender.setParameters(params); } }这里有个坑我踩过:直接改params.encodings[0]之前必须先调getParameters()拿到当前值,改完再setParameters()写回去。如果encodings是空数组,得先塞一个空对象进去,否则调用会静默失败——没有报错,但码率就是不生效,特别难查。
更彻底的做法是simulcast,同一个视频源编成三档不同分辨率的流同时发出去,接收端根据网络状况选择收哪一档。这在多人会议里几乎是必备的,一对一场景下用不上,因为对端只有一个,直接调码率就够了。
const transceiver = pc.addTransceiver(track, { direction: 'sendonly', sendEncodings: [ { rid: 'low', maxBitrate: 150000, scaleResolutionDownBy: 4 }, { rid: 'mid', maxBitrate: 500000, scaleResolutionDownBy: 2 }, { rid: 'high', maxBitrate: 1500000, scaleResolutionDownBy: 1 } ] });scaleResolutionDownBy是缩小倍数,4 表示宽高各缩到四分之一(面积十六分之一)。三档的码率不要拉开太夸张,否则切换时画质跳变明显。
6. 上线前绕不开的四件事:兼容、权限、移动端和压测
6.1 兼容性:别指望各家行为一致
WebRTC 的 API 层面基本统一了,但边缘行为差异不小。我整理过一张自己踩过的对照表:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 移动端苹果系浏览器首次播放无声 | 自动播放策略限制,需要用户手势解锁 | 在用户点击事件里先播一段静音,或设置playsinline并监听手势 |
某些浏览器getParameters().encodings为空 | 实现差异 | 先赋[{}]再设参数 |
| 移动端切后台后视频冻结 | 页面不可见时媒体被挂起 | 监听visibilitychange,恢复时重新拉流或restartIce |
| 屏幕共享无法采集系统声音 | 各平台支持度不同 | 不做承诺,提供“仅共享画面”的降级选项 |
addIceCandidate偶发报错 | 候选到达早于远端描述 | 缓存候选(见 3.3 节) |
这份表最有用的一条是自动播放策略。移动端浏览器基本都禁止无用户交互的自动播放,包括带音轨的视频。如果你的页面是“对方来电,直接开始播放”,必然被拦。解决方案是在用户之前的一次点击(比如“接听”按钮)里,先创建一个静音的AudioContext或者播一段空的音频,把播放权限“解锁”,后续的程序化播放才能生效。
6.2 HTTPS 与权限:这件事没有绕过的余地
getUserMedia和getDisplayMedia只在安全上下文里可用。https://和localhost算安全上下文,http://加 IP 地址不算。这意味着你在本地用localhost:3000测得好好的,部署到http://192.168.1.100让同事测试,navigator.mediaDevices直接是undefined。
我见过有人为了图省事在内网用 http 部署,然后花半天排查“为什么 API 不存在”。答案很简单,就是不给你用。内网测试要么配个自签证书,要么用 ngrok 这类隧道工具给个临时 https 域名。
权限被拒绝的处理也得写。用户点了“拒绝”之后,getUserMedia会抛NotAllowedError,而且同一域名下再调不会重新弹窗,会直接拒绝。这时候页面上必须给明确指引,告诉用户去哪里改权限,否则用户只会觉得“这网站坏了”。
try { const stream = await navigator.mediaDevices.getUserMedia(constraints); } catch (err) { if (err.name === 'NotAllowedError') { showTip('摄像头/麦克风权限被拒绝,请在浏览器地址栏左侧的图标里重新允许'); } else if (err.name === 'NotFoundError') { showTip('没有检测到可用的摄像头或麦克风'); } else if (err.name === 'NotReadableError') { showTip('设备被其他程序占用,请关闭其他使用摄像头的应用后重试'); } }NotReadableError这个错误特别常见,用户在开会前忘了关掉另一个会议软件,摄像头被独占,你的页面就抓不到。给出准确的提示,能省掉大量客服工单。
6.3 移动端:后台、锁屏、音频路由
移动端的三个坑按出现频率排序:切后台、锁屏、音频路由。
切后台最直接,页面visibilitychange变成 hidden 之后,很多浏览器会暂停视频轨道的采集,回到前台也不会自动恢复。处理方式是在页面重新可见时检查轨道状态:
document.addEventListener('visibilitychange', async () => { if (document.visibilityState !== 'visible') return; const track = localStream?.getVideoTracks()[0]; if (track && track.readyState === 'ended') { // 轨道已经被系统回收,需要重新采集并替换 const newStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); const newTrack = newStream.getVideoTracks()[0]; const sender = pc.getSenders().find((s) => s.track?.kind === 'video'); if (sender) await sender.replaceTrack(newTrack); } });replaceTrack这个方法比“删掉旧轨道再加新轨道”优雅得多,它不会触发重新协商,切换瞬间对方几乎感知不到。缺点是新旧轨道的分辨率、帧率必须兼容,否则会有短暂的画面异常。
锁屏的问题在于音频路由。手机上插着耳机、连着蓝牙音箱、用着外放,这三种状态的切换不会自动通知你。用户从蓝牙切到外放,很可能出现对方听不到声音的情况。这个没法完全在网页层解决,只能靠提示用户“如遇声音异常请检查设备音频输出设置”。
6.4 压测与监控:该盯哪几个指标
WebRTC 的压测和普通接口压测完全不是一回事。接口压测看 QPS 和响应时间,WebRTC 压测看的是并发连接数、服务器带宽、以及每个会话的质量指标。
我自己会盯这几个数:
- 连接成功率:发起通话到
connectionState === 'connected'的比例,低于 95% 就该查 TURN 配置了。 - 直连率:用
relay候选建立的比例,越低越省钱。健康值在 80% 以上。 - 建连耗时:从点击呼叫到看到对方画面的时间,超过 3 秒用户就会觉得卡。
- RTT 和丢包率:
candidate-pair里的currentRoundTripTime和inbound-rtp里的packetsLost。RTT 超过 200ms、丢包超过 3% 都算质量下降。 - TURN 流量:这是真金白银,按天统计,突然飙升一定是直连率出了问题。
这些数据需要在客户端埋点上报。我的做法是在连接建立和结束时各采样一次getStats(),把关键字段汇总成一个对象发给日志服务,通话过程中每 30 秒再补采一次,用于画出质量曲线。这样出问题时能精确定位到是哪个环节掉的链子,而不是只知道“用户反馈通话卡”。
压测工具方面,纯浏览器方案跑不了多少并发,几百个实例就撑不住了。真实的并发压测通常得用专门的信令客户端模拟器,或者干脆上云端的设备农场。我个人的经验是,与其追求高并发数字,不如先把 50 个并发的场景跑稳,因为这 50 个连接里暴露的问题,比 5000 个连接的数字有意义得多。
再补一个实战里的小技巧:在所有pc对象的创建处统一加一个dataChannel作为“探针”。这个通道不传业务数据,只定时发心跳,一旦ondatachannel的onclose触发,就说明底层传输断了,可以立刻触发重连逻辑。这比等connectionState变成failed(通常要等十几秒)快得多,用户几乎感知不到中断。
这套东西我自己用了两年多,从最初那个跑了三天才连通的 Demo,到现在能稳定支撑百人并发的系统,中间踩的坑基本都在上面几节里了。真正开始写的时候,建议先把第 3 节那份最小代码完整跑通一遍,再回头去补第 2 节的信令和第 4 节的中继。一次性把六个模块全搭上,往往会卡在某一个细节上找不到北。