7.2 端到端完整实战 本节摘要:前面的零件都在这一节总装:一次完整可跑的 1v1 通话,从信令接入、采集、协商、连接到渲染与挂断,全代码走读;随后给出八个真实项目里最高频的翻车点清单。本节的目标不是再讲一遍原理,而是把原理变成一条你能亲手复制、运行、改坏、修好的完整链路。 读完本节你能 阅读完本节,你应当能够: 按「信令层、协商层、媒体层」三段结构组织通话代码,而不是把所有逻辑糊在一起; 完整写出主叫与被叫两侧的协商流程代码; 正确处理远端轨道的到达时机与渲染; 实现挂断与资源释放,避免设备占用残留; 对照八个翻车点自检代码,逐条说清病因与修法。
本节摘要:前面的零件都在这一节总装:一次完整可跑的 1v1 通话,从信令接入、采集、协商、连接到渲染与挂断,全代码走读;随后给出八个真实项目里最高频的翻车点清单。本节的目标不是再讲一遍原理,而是把原理变成一条你能亲手复制、运行、改坏、修好的完整链路。
阅读完本节,你应当能够:
教程代码与产品代码的差距,通常不在主流程——主流程就那几步——而在边角:谁先发起、中途谁挂了、再来一次时上一次的资源清干净没有。本节的实现刻意按「三段结构」组织:信令层只管收发消息(第 3 章的封装原样复用);协商层只管 offer-answer 与候选;媒体层只管采集与渲染。三层各自可测、可换,出问题时能按层定位而不是满文件找。
先看总装图,再逐段落码。

协商层主叫侧(媒体层采集代码见第 2 章,此处直接复用其产物):
class CallSession { constructor(signaling, stream) { this.signal = signaling; this.stream = stream; // 第 2 章 acquireMedia 的产物 this.pc = null; } async start() { this.pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.example.net:3478' }, { urls: 'turn:turn.example.net:3478', username: 'u', credential: 'p' }, ], }); this.wireEvents(); this.stream.getTracks().forEach((t) => this.pc.addTrack(t, this.stream)); // 主叫动作:生成并固化 offer,再经信令送出 await this.pc.setLocalDescription(await this.pc.createOffer()); this.signal.send({ type: 'offer', payload: this.pc.localDescription }); } async accept(remoteOffer) { this.pc = new RTCPeerConnection({ /* 同上 iceServers */ }); this.wireEvents(); // 被叫纪律:先设远端,再加轨道,后作答 await this.pc.setRemoteDescription(remoteOffer); this.stream.getTracks().forEach((t) => this.pc.addTrack(t, this.stream)); await this.pc.setLocalDescription(await this.pc.createAnswer()); this.signal.send({ type: 'answer', payload: this.pc.localDescription }); } wireEvents() { this.pc.onicecandidate = ({ candidate }) => { if (candidate) this.signal.send({ type: 'candidate', payload: candidate }); }; this.pc.ontrack = (event) => { remoteVideo.srcObject = event.streams[0]; // 对端画面在此交付 }; this.pc.onconnectionstatechange = () => { if (this.pc.connectionState === 'failed') this.onFailed(); }; } async onRemoteCandidate(c) { await this.pc.addIceCandidate(c); } async onRemoteAnswer(a) { await this.pc.setRemoteDescription(a); } hangup() { // 释放纪律:先停轨道释放设备,再关连接 this.stream?.getTracks().forEach((t) => t.stop()); this.pc?.close(); this.signal.send({ type: 'bye' }); } }
信令消息的分派只在四行之内(第 3 章的封装):
signal.on('offer', (m) => incomingCall(m.payload)); signal.on('answer', (m) => session.onRemoteAnswer(m.payload)); signal.on('candidate', (m) => session.onRemoteCandidate(m.payload)); signal.on('bye', () => session.hangup());
这八个点覆盖了真实项目里绝大多数「代码看着对、就是不通」:
| 翻车点 | 病因 | 修法 |
|---|---|---|
| iceServers 忘配或配错 | 只有 host 候选,跨网必败 | 第 4 章部署清单对账 |
| 被叫先加轨道后设远端描述 | answer 与轨道顺序错乱,方向异常 | 严格「先远端、再轨道、后作答」 |
| 只有一端 addTrack | 对端无发送内容,黑屏 | 检查两端轨道结构 |
| ontrack 挂在创建之前的 pc 上 | 监听丢失,画面永不渲染 | 先建 pc 再 wireEvents |
| 数据通道 send 早于 open | 抛异常,消息丢失 | open 后再发,或挂待发队列 |
| 挂断只关连接不停轨道 | 摄像头指示灯常亮 | 轨道 stop 与连接 close 成对 |
| 信令乱序(answer 迟到) | setRemoteDescription 抛错 | 协商代数校验(第 3 章案例) |
| 弱网下重复点呼叫 | 多个连接并存互相干扰 | 会话状态机,禁止并发建立 |
⚠️ 常见坑:把这段代码直接复制两份跑在两个标签页里测试时,本机两个标签页会互相当成「同源同设备」,部分浏览器合并媒体处理,现象诡异。正经自测请用两台设备,或一台设备一个用浏览器一个用无痕窗口加虚拟摄像头。
背景:团队按上述骨架联调,通话正常,但挂断后再次呼叫必失败,第二次通话双方都黑屏;重启页面又能恢复一次。
操作:按层排查。信令层消息序列正确;协商层日志显示第二次的 offer 正常送达;媒体层发现第二次采集复用了第一次的 stream 对象——而第一次挂断时轨道已被 stop,处于 ended 终态。把 ended 轨道 addTrack 进新连接,浏览器不报错但发不出任何内容。修复:挂断后置空会话持有的流引用;每次 start 前重新走 acquireMedia 采集新轨道;并在 acquireMedia 内部增加「旧轨道未释放先释放」的防御。
结果:连续多次呼叫与挂断循环测试通过;顺手在挂断流程加了「连接关闭后清空 video 标签的 srcObject」,消除了挂断瞬间最后一帧残留的产品瑕疵。
解读:这类 bug 的教育意义在于轨道的生命周期是「一次性的」:ended 是终态,不因重新使用而复活。资源管理的问题在 WebRTC 里会以「第二次不工作」的形态出现,而第一次永远完美——凡是「第二次才坏」的 bug,优先怀疑上次没清理干净。
变式:接入 SFU 时这份骨架的改动集中在两处:协商对端从「另一台浏览器」换成「SFU 服务器」(协议由服务器定义,通常是收发分离的两种 transceiver 配置);ontrack 会按流多次触发(每人一条),渲染层要按流标识动态管理 video 标签。骨架的分层结构在这里兑现价值——只动协商层与媒体层的接口实现,信令层原样复用。