3.1 信令职责与 offer-answer 模型 本节摘要:为什么两个浏览器明明都有对方的网页地址,却还是「素不相识」?因为 HTTP 世界里页面之间没有身份与长连接,WebRTC 必须借助一条自建的信令通道完成互识。本节讲清信令要搬哪几类消息、offer-answer 模型如何运作、设置描述的次序为什么碰不得,以及两端同时开口时的协商冲突怎么化解。本节结束,你就能写出协商主流程的完整代码。 学习目标 阅读完本节,你应当能够: 列出信令通道需要承载的全部消息类型与各自的时机; 说出 createOffer 之后必须先 setLocalDescription 再发送的技术原因; 解释 offer 与 answer 在方向与编解码选择上的不对称关系;
本节摘要:为什么两个浏览器明明都有对方的网页地址,却还是「素不相识」?因为 HTTP 世界里页面之间没有身份与长连接,WebRTC 必须借助一条自建的信令通道完成互识。本节讲清信令要搬哪几类消息、offer-answer 模型如何运作、设置描述的次序为什么碰不得,以及两端同时开口时的协商冲突怎么化解。本节结束,你就能写出协商主流程的完整代码。
阅读完本节,你应当能够:
页面上线后,你的浏览器与客户的浏览器之间没有任何直接联系:各自向服务器请求页面,各回各家。RTCPeerConnection 构造出来时也是一座孤岛——它不知道对方是谁、对方支持什么、对方在网络哪里。把这三样「不知道」补齐,就是信令的全部职责:
第一样,媒体能力互知。我的浏览器支持 VP8 与 H264,你的支持 AV1 与 H264,双方得挑一个交集——这份能力清单与勾选结果就是 SDP,经由信令交换。第二样,网络地址互知。我藏在路由器后面,你也藏在另一台路由器后面,彼此的地址要互相递话——这些地址叫 ICE 候选,也经由信令交换。第三样,业务语义互通。谁先说话、加入哪个房间、通话挂断由谁发起,这些是产品逻辑,信令通道顺手承担。
所以信令服务器不需要任何 WebRTC 特性——它只是个转发文本的邮局。能力清单与地址如何生成是浏览器的事,邮局只管按时按序送到。这个职责边界划清后,信令服务的实现自由度极大:WebSocket 最常用,长轮询也能凑合,甚至两个人用二维码互传文本都能完成协商。

协商代码有一套严格的次序纪律,乱一步就卡死。以发起端为例:
// 发起端 A 的完整协商动作 const pc = new RTCPeerConnection(config); localStream.getTracks().forEach((t) => pc.addTrack(t, localStream)); const offer = await pc.createOffer(); // 生成能力清单(未生效) await pc.setLocalDescription(offer); // 固化为本端描述(开始收集候选) signal.send({ type: 'offer', sdp: pc.localDescription }); pc.onicecandidate = ({ candidate }) => { if (candidate) signal.send({ type: 'candidate', candidate }); };
两处细节是初学者的翻车点。其一,createOffer 与 setLocalDescription 必须成对且按序:只有 setLocalDescription 执行后,浏览器才认为「我方意图已定」,开始收集候选地址。先发 offer 再设描述(或干脆忘设),对端回了 answer 你却无法应用。其二,应答端必须先 setRemoteDescription 再 createAnswer——你得先知道对方提了什么,才能回答。次序颠倒会抛 InvalidStateError 或产生内容为空的 answer。
应答端 B 的动作镜像对称:
// 应答端 B:先记下对方意图,再作答 await pc.setRemoteDescription(desc.offer); localStream.getTracks().forEach((t) => pc.addTrack(t, localStream)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); signal.send({ type: 'answer', sdp: pc.localDescription });
候选消息的收发与 SDP 互相独立、并行进行,收的一方直接喂进连接:
// 两端通用:收到候选就加入(Trickle ICE:随到随收) signal.on(({ type, candidate }) => { if (type === 'candidate') { pc.addIceCandidate(candidate).catch((e) => console.warn('候选无效:', e)); } });
第三个知识点是协商冲突(glare):两端同时 createOffer,双方都处在「等待 answer」状态,对方的 offer 却先到了——状态机打架。规范给的标准解法是礼貌协商:预先约定一端为 polite(礼貌方)、一端为 impolite(强硬方)。冲突时,礼貌方回滚自己的 offer 接受对方的,强硬方忽略对方的 offer 坚持自己的。代码骨架是给 onnegotiationneeded 加一个忽略标志位,配合 rollback:
// 礼貌协商骨架:冲突时礼貌方回滚 let makingOffer = false; pc.onnegotiationneeded = async () => { try { makingOffer = true; await pc.setLocalDescription(); // 自动 createOffer signal.send({ type: 'description', sdp: pc.localDescription }); } finally { makingOffer = false; } }; // 收到对方 description 时: // const offerCollision = desc.type === 'offer' && (makingOffer || pc.signalingState !== 'stable'); // polite 端遇冲突:await pc.setRemoteDescription(desc) 内含隐式回滚 // impolite 端遇冲突:忽略该 offer
⚠️ 常见坑:每次 addTrack 都会触发 onnegotiationneeded,很多人在协商完成前又动态加轨道,导致 offer 发了个半成品。动态加轨前确认 signalingState 已回到 stable。
协商代码里混入大量信号收发细节,生产实现应把「连接管理」与「协商逻辑」分层。给一个简洁的信令封装约定:信令层只做三件事——连上、发 JSON、按类型派发;协商层只跟信令层对话,不感知 WebSocket 的存在。这样换信令通道(比如换成自有长连接网关)时协商代码零改动。
工程上还有两个取舍值得记录。取舍一:Trickle 与 Non-trickle。Trickle 边收集边发,连接建立快,但要求信令通道支持任意时机的消息;Non-trickle 等候选收集完放进 SDP 一起发,消息少但首帧慢。会议类产品选 Trickle,弱网移动端可考虑收集完再发以减少信令抖动。取舍二:谁发起。固定「主叫发起」最简单;会议室场景则常用「房间第一个视图拥有者发起」或由服务端指定,避免多个客户端同时抢 offer。
背景:某产品的移动端偶发「呼叫接通后双方都黑屏」,日志显示信令消息都发出去了,iceConnectionState 却停在 new。
操作:排查发现移动端弱网下信令 WebSocket 重连,重连后客户端把缓存的 answer 又发了一遍,而发起端此时已经完成协商并因业务超时重新发起了一次新协商——旧的 answer 到达时连接正处在 have-local-offer 状态,setRemoteDescription 抛出异常被 catch 静默吞掉,连接从此卡死在错误状态。
结果:修复分三步:信令消息带上递增的协商代数(每发起一轮协商加一);收到过期代数的消息直接丢弃并记日志;setRemoteDescription 的失败不再静默,改为触发一次受控的重新协商。
解读:这类故障的本质是「协商没有版本概念」。offer-answer 是一份需要双方在线确认的合同,网络抖动会让合同乱序到达,给每份合同编上号是唯一稳妥的解法。此后该团队把「协商代数」写进了信令协议规范,同类故障再未复现。
变式:如果信令通道本身不可靠(如某些推送通道),可以在应用层实现请求-应答匹配:发出 offer 后挂起一个超时器,超时未收到 answer 就以相同代数重发,配合指数退避。这与第 4 章 ICE 重试的思路同源——在不稳定的信道上,可靠性是应用层自己挣来的。