第一次琢磨浏览器视频聊天,直觉上觉得跟 WebSocket 是一路货:连上服务器,write 视频流,对端 onmessage 接住画面。真写第一行代码就卡住。浏览器之间压根没有一个能直接连的地址。
WebRTC 干的是另一件事:先靠服务器牵线,再尽可能走点对点直连。它交到你手里的东西,跟"浏览器版 WebSocket"这个直觉差了十万八千里。下面按踩坑的顺序把它拆开。
信令服务器,WebRTC 自己懒得写的那半截
getUserMedia 顶多把本地摄像头麦克风的流拽出来,要发出去得靠 RTCPeerConnection。两个浏览器想搭上线,头一步得互相知道对方是谁、在哪个网、能收什么格式。这件最要紧的事,WebRTC 规范压根没管,只甩下一句:两端必须交换两类文本。
- SDP(Session Description Protocol):我有哪些音视频轨道、支持哪些编解码、我大概有哪些网络地址候选。
- ICE candidate:一个具体能连通的地址,可能是本机地址、NAT 反射地址、或者中继地址。
用什么通道把这俩东西递过去?规范一个字没提。WebSocket 行,SSE 行,HTTP 长轮询也行,早年甚至有人演示过把 SDP 复制粘贴进聊天框、让两个人手动对。为什么非得这么绕?因为两端多半躲在 NAT 后面,没有稳定公网地址,开局互相不知道对方在哪儿,总得有个一直在线的人先帮它们对一下暗号。
这个人就是信令服务器。它活儿只有一件:把 A 的 offer 和 candidate 转给 B,把 B 的 answer 和 candidate 转回 A。媒体流不走它,所以它轻得很,一台普通 WebSocket 服务能扛一大堆连接。
offer / answer 加 ICE,那场看着乱的握手
握手顺序其实是定的,只是 ICE 收集和 SDP 交换在后台并行跑,头回看会觉得一团乱。代码长这样:
// 信令通道:WebRTC 不提供,这里用 WebSocket 自己实现 const signaling = new WebSocket('wss://signal.example.com'); // 1. 拿本地音视频(必须在 HTTPS 或 localhost 下,否则 getUserMedia 直接拒绝) const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true, }); // 2. 建连接,先给 STUN(查出 NAT 后的公网地址用) const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], }); // 3. 本地轨道塞进连接 localStream.getTracks().forEach((track) => pc.addTrack(track, localStream)); // 4. 本地每收集到一个候选地址,就实时发给对端(trickle ICE) pc.onicecandidate = (e) => { if (e.candidate) { signaling.send(JSON.stringify({ type: 'candidate', candidate: e.candidate })); } }; // 5. 收到对端媒体时,挂到 <video> 上 pc.ontrack = (e) => { remoteVideo.srcObject = e.streams[0]; }; // 发起方:造 offer -> 设为本地描述 -> 发信令 const offer = await pc.createOffer(); await pc.setLocalDescription(offer); signaling.send(JSON.stringify({ type: 'offer', sdp: offer.sdp })); // 接收方:收到 offer 后回 answer signaling.onmessage = async (msg) => { const { type, sdp, candidate } = JSON.parse(msg.data); if (type === 'offer') { await pc.setRemoteDescription({ type: 'offer', sdp }); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); signaling.send(JSON.stringify({ type: 'answer', sdp: answer.sdp })); } else if (type === 'candidate') { await pc.addIceCandidate(candidate); } };
上面这段有个新手必栽的坑:addIceCandidate 得等 setRemoteDescription 真正完成之后才能调。代码里万一候选比 offer 先到(网络抖一下太常见),addIceCandidate 直接甩你一个 Failed to execute 'addIceCandidate'。
很多人第一反应是信令丢包了,翻半天日志才发现是候选先到了。靠谱的做法不是赌谁先到,而是先把候选塞进队列,等远端描述 set 完再一个个补。setRemoteDescription 是异步的,它 await 回来之前别碰候选。再往上一层,生产代码直接照 MDN 的 Perfect Negotiation(完美协商)模式写,用 polite / impolite 角色加 makingOffer 状态机去消化"两边同时发起协商"的那种冲突,比自己堆 if 稳。
STUN 够用,可 P2P 常常连不上
ICE 收集候选时按三类地址轮着试:
- host:本机网卡地址,同机或同局域网直接通。
- srflx(server reflexive):经 STUN 查出来的"我家路由器对外的公网地址"。大多数家庭宽带是锥形 NAT,对方拿这个地址能连回我,P2P 直连成功率挺高。
- relay:经 TURN 分配的中继地址,直连全失败时兜底。
麻烦出在对称 NAT。每次往不同外部目标建连接,路由器都开一个新端口。STUN 帮你查到的反射地址只对"查 STUN 那一次"管用,对方拿着它来连,端口对不上,进不来。企业网、部分运营商的 4G、一些校园网就是这种。
P2P 这条路一断,就只能走 TURN,连媒体也经服务器中转。TURN 是整个方案里唯一要真金白银买带宽的地方,所有音视频包都得从它身上过。720p 双向各约 1~2 Mbps,几路一并发就是实打实的流量费,不像信令服务器那样只传几 KB 文本。
所以经验很直白:本地联调、内网 demo、1 对 1 测试,配个免费公共 STUN 就看着能用了;只要产品要面向真实用户上线,TURN 不是可选项,是必选项。不然那一小撮对称 NAT 用户永远卡在"连接中",你翻日志只会看到 ICE 状态停在 checking,迟迟到不了 connected。
媒体加密这层不用你操心。WebRTC 强制走 DTLS-SRTP,密钥在握手时用 DTLS 协商,媒体用 SRTP 加密,浏览器不让明文 RTP 出网。规范替你兜了底,不用自己写。
被低估的 RTCDataChannel
WebRTC 不是只能传音视频。RTCPeerConnection 上还能开 RTCDataChannel,底下走 SCTP over DTLS over UDP,自带可靠或不可靠、有序或无序的模式选择:
const dc = pc.createDataChannel('chat', { ordered: true }); dc.onopen = () => dc.send('对方已上线'); dc.onmessage = (e) => console.log('收到:', e.data);
用处通常是文件点对点直传、小游戏状态同步,或者把某些低延迟控制信令从 WebSocket 旁路掉。它比 WebSocket 少一跳服务器,代价是每次都得先走完上面那套握手,建立连接的启动成本比直接开个 WebSocket 高。轻量、偶尔才发一次的通信,别为了"纯 P2P"硬换。
生产环境,纯 P2P 撑不住群聊
1 对 1 是 P2P 的甜区:两个浏览器直连,服务器只在建连那一刻露个面。人一多,Mesh 就垮。N 个人开会,每个人得跟另外 N-1 个人各建一条 RTCPeerConnection,自己的上行得发 N-1 份同样的流。
算笔账:4 人通话,每人上行变 3 倍,下行也 3 份,CPU 编码和带宽先到顶。不是对方网差,是你自个机器先扛不住。
群聊的正经解法是 SFU(Selective Forwarding Unit,比如 mediasoup、LiveKit、Janus):每个客户端只跟服务器建一条连接,上行一份自己的流,服务器负责转发给其余 N-1 人(下行 N-1 份)。SFU 只转发不转码,算力可控,带宽模型也从"每人 N-1 份上行"降成"每人 1 份上行加 N-1 份下行"。再往上就上 MCU 做服务端混流,不过那是另一笔算力和延迟的账。
判断就一句:小工具、1 对 1、内网或可控网络,P2P 真够用,别上 SFU 徒增运维面;要服务真实公网用户、要兼容弱网和对称 NAT、要群聊,那就 TURN 加 SFU 一起上,别为了"纯 P2P 更酷"硬扛规模。
回到开头那个问题
两个浏览器视频通话,为什么非得先搭一台信令服务器?因为 NAT 把两端都藏起来了,得有个中间人先把 SDP 和 ICE candidate 对一遍暗号,剩下的媒体才可能在两端之间直接跑。把四层记牢:信令牵线、媒体尽量直连、直连不了 TURN 兜底、人一多上 SFU。WebRTC 就从一堆神秘 API 变成一个能逐步调试的系统:连不上先瞅 ICE 状态,卡 checking 就怀疑对称 NAT 和 TURN,群聊卡顿就看是不是还在用 Mesh。