先问大家一个问题:当你写下pc.addTrack(track, localStream)的时候,第二个参数stream到底起了什么作用?如果你答不上来,或者认为MediaStream本身就是一个装着音视频数据的“管子”,那说明你还没有绕出 WebRTC 里 Track 与 Stream 的坑。我这些年做音视频项目,见过太多把这两者混为一谈的代码——有人把getUserMedia返回的stream整个塞进video.srcObject就完事,也有人说“我把流停掉了啊”,结果只调了stream.stop()这种根本不存在的 API,真正让设备熄灯的其实是track.stop()。
这篇我会从 API、SDP、RTP 底层三个层面把 Track 与 Stream 的本质掰开讲清楚,为什么 Stream 只是容器、Track 才是真正的媒体数据源,并给出可直接抄作业的点对点示例和排查经验。适合刚入门 WebRTC 的开发者,也适合那些被“流断了”“流没声音”之类问题折腾过的人。看完你至少能回答这几个问题:addTrack 的第二个参数为什么不是多余的?远端 ontrack 里的event.streams到底哪儿来的?以及为什么删掉一条轨道,远端看到的状态和你想象的不太一样。
1. 先搞清楚:一条流和一条轨,到底谁在承载数据
1.1 一句话本质:Stream 是容器,Track 是数据源
很多从传统视频流媒体转过来的人,天然会把“流”理解成一路连续的视频数据,于是第一次接触 WebRTC 时,会觉得MediaStream就是那根“水管”。但 WebRTC 里的设计并不是这样。更准确地说,MediaStream只是一个逻辑分组,它的职责是“把若干条轨道打包成一个整体”,真正在采集、编码、封装 RTP 包的是MediaStreamTrack。
打个比方:一个摄像头是饮水机,一个麦克风是水壶,你往杯子里倒水,杯子就是MediaStream。饮水机和水壶可以单独存在,杯子只是一个组合工具。没有杯子的饮用水仍然是水,但你要端给客人喝,杯子能让你一次端走“纯净水+柠檬水+一点冰块”,而且告诉客人“这是一杯饮料”。同理,摄像头视频轨和麦克风音频轨没有任何物理上的关联,但把它们放进同一个MediaStream,接收端就可以按照这个分组去渲染和播放。
理解这一点是本文的地基。后面要讲的所有 API 行为、SDP 结构、远端事件参数,都是在围绕“谁是真数据,谁是标签”这两件事展开。
1.2 从 getUserMedia 返回值看两者关系
你调用navigator.mediaDevices.getUserMedia时,浏览器内部做的其实是三件事:打开设备、创建媒体源、把媒体源包装成MediaStreamTrack并塞进一个MediaStream。所以返回值看起来像一个“视频文件”,实际上它是一个只有分组的壳。
试试这段代码,你会看得特别清楚:
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720, frameRate: 30 }, audio: { echoCancellation: true, noiseSuppression: true } }); console.log(stream.constructor.name); // MediaStream console.log(stream.getTracks().length); // 2(一视频一音频,取决于设备) console.log(stream.getVideoTracks()); // [MediaStreamTrack ...] console.log(stream.getAudioTracks()); // [MediaStreamTrack ...] console.log(stream.active); // true,表示容器内存在 live 的轨道关键问题来了:stream.active这个属性经常被误解。它只表示“当前至少有一条 readyState 为 live 的轨道”,但它不是一个开关,你不能用stream.active = false来停止采集,设置了也没效果。真正决定设备是否在工作,要去看每条轨道的状态和enabled标志。
轨道上有三个属性最容易混淆:
readyState:只有live和ended两种,轨道资源是否已经在传输/捕获,它由浏览器内部决定。设备拔掉、页面失去权限、调用stop()后,状态才会变化。muted:表示“这一刻没有数据可用”,例如麦克风被系统静音、摄像头被别的应用独占,但轨道并没有结束,随时可能恢复。enabled:一个“软开关”,设置enabled = false后,数据内容会被替换成黑帧/静音帧,但设备仍然在采集,CPU 和带宽依然在消耗,只是内容被遮挡了。
这三者的区别,直接决定你调试时看哪个字段、用什么 API 去控制采集。很多新手把enabled = false当成“省电模式”,其实完全不是。
1.3 为什么容器这个概念不能省
既然 Track 才是数据源,那 Streaming 分组还有什么意义?答案是为了“业务语义”。一个MediaStream可以包含一路视频和一路音频,接收端拿到后可以直接把它塞进一个<video>标签;它也可以包含两路视频和一路音频,接收端拿到后可以按组处理字幕、画中画、录制混流。
在实际的多人会议里,每个参会者通常把自己的摄像头和麦克风组合成一个MediaStream,房间里的逻辑是“一个用户对应一个流”。远程端在收到该用户的轨道时,把它们还原到同一个Stream分组中,上层只关心“用户 A 的流”而不用去维护一堆零散的 Track 映射关系。这个概念在 SDP 里通过 MSID 来承载,后面第二章会看到具体长相。
所以,容器不是多余的“包装纸”,它是 WebRTC 把多条并行媒体组织给上层使用的基本单位。不夸张地说,你看不懂ontrack里的event.streams数组,就到不了能自如管理多路音视频的水平。
2. 在 RTCPeerConnection 里,Track 和 Stream 是如何被“打包”传输的
2.1 addTrack 的第二个参数到底在干什么
老版本 WebRTC 里有个addStream(),现在已经废弃了,官方推荐用addTrack()。但很多人只是机械地照抄示例代码,写pc.addTrack(track, localStream)时根本不知道localStream用来干嘛。
这个第二个参数的作用是:告诉浏览器“这条轨道归到哪个 Stream 分组下面”。浏览器会把Stream.id和Track.id写入本地 SDP 的a=msid行。对端在收到 SDP 和 RTP 包后,可以通过这个 MSID 还原出“某条轨道属于哪个 Stream”。
另一个细节:addTrack其实可以传多个 Stream,也就是说一条轨道可以同时属于多个流组:
pc.addTrack(videoTrack, streamA); // 最常见用法 pc.addTrack(videoTrack, streamA, streamB); // 同一条轨同时挂在两个流下但注意,这里说的是“可以在不同分组里引用同一个 Track”,不代表你可以把同一条 Track 通过addTrack在同一个RTCPeerConnection里添加两次。如果对同一个 Track 调用两次addTrack,规范明确会抛出InvalidTrackError。原因是 SDP 里不能出现两条相同的 m-line 对应同一个轨道资源,发送端也无法区分你要发的到底是哪一份。
这种设计的好处是:分组信息完全通过信令面和维护在 SDP 里,不影响 RTP 媒体面。媒体面只认 SSRC 和 Track 的关系,而分组信息只存在于会话协商阶段。
2.2 一轨一 m-line:Unified Plan 下的轨道与流映射
2018 年之前,Chrome 默认用的是 Plan B 协议,一条 m-line 可以塞多个 Track,用a=ssrc-group和多个а=ssrc行来区分。Plan B 实现简单,但扩展性差,尤其是遇到 simulcast、多路视频混流时非常别扭。现在所有主流浏览器都转向 Unified Plan:每条轨道独占一个 m-line,映射关系变成“一个 m-line ↔ 一个 Track ↔ 一个流(或不挂流)”。
下面对比一下 SDP 里的长相。Plan B 大致是这样:
m=video 9 UDP/TLS/RTP/SAVPF 96 a=ssrc:111 cname:abc a=ssrc:222 cname:abcUnified Plan 则是每个视频轨一个 m-line,每行里会带明确的 MSID 信息:
m=video 9 UDP/TLS/RTP/SAVPF 96 a=msid:streamA track1 a=ssrc:111 msid:streamA track1 a=ssrc:111 cname:abc m=video 9 UDP/TLS/RTP/SAVPF 96 a=msid:streamA track2 a=ssrc:222 msid:streamA track2 a=ssrc:222 cname:abc你看,a=msid:<streamID> <trackID>这行就是容器和轨道的“户口本”。至于同一个流下有多条轨道,那就让多个 m-line 都写上同一个streamID。对端解析 SDP 时,就会把轨道 1 和轨道 2 归到同名的streamA下。
为什么要推行这种“一人一位”的模型?因为媒体能力协商变得非常灵活。每条 m-line 可以独立声明sendrecv、sendonly、recvonly、inactive四种方向,而 Plan B 很难精细控制“这一路只能收、这一路只能发、这一路要暂停发送但保留接收”。如果你以后要动态增减轨道或者做大小流,Unified Plan 几乎是必须的。
2.3 远端如何通过 ontrack 还原 Track 和 Stream 关系
当对端收到媒体时,不会像“一个文件下载完成”那样一次性通知你,而是每条 m-line 协商成功后触发一次ontrack事件。事件参数里最重要的三个字段是event.track、event.streams和event.receiver:
pc.ontrack = (event) => { const remoteTrack = event.track; // MediaStreamTrack,来自远端 const remoteStreams = event.streams || []; // MediaStream 数组 const remoteReceiver = event.receiver; // RTCRtpReceiver const stream = remoteStreams.find(s => s.id.includes('streamA')); if (stream) { videoEl.srcObject = stream; // 直接把流挂上去 } console.log('收到远端轨道', remoteTrack.kind, remoteTrack.id, 'readyState:', remoteTrack.readyState); console.log('它所属的流', remoteStreams.map(s => s.id)); };这里有一个 WebRTC 入门用户非常容易踩的坑:ontrack触发时,event.streams数组有可能不是空的,但如果你只是要渲染视频,直接把event.track单独做成一个 MediaStream 也能播放:
// 如果一定要用单条 track,可以手工包一个 Stream const singleStream = new MediaStream([event.track]); videoEl.srcObject = singleStream;但这样会丢失轨道原本的“分组语义”。在一个多人会议里,你收到的可能是两个人各自的一频轨和视频轨,它们各自有不同的 stream id 和 track id。如果你不按 stream 分组,想“把 A 的视频和 A 的音频绑定在一起”就要自己维护一张表,代码很快就臭了。所以,耳听为虚,眼见为实——直接在event.streams基础上做业务分组,是最省心的写法。
3. 实操:从本地采集到双端互通,看清 Track/Stream 的完整生命周期
3.1 本地采集与轨道控制:枚举、启停、clone
先看轨道枚举和控制。拿到MediaStream后,你常用的是getTracks()拿到所有轨道,再用getVideoTracks()和getAudioTracks()按类型筛选。但注意这几个方法返回的都是“引用”,你修改的是原 Track,不是在操作副本。
实际调试时可以用getSettings()看当前分辨率、帧率、设备 ID:
const [videoTrack] = stream.getVideoTracks(); const settings = videoTrack.getSettings(); console.log(settings.width, settings.height, settings.frameRate, settings.deviceId);然后再讲两个特别容易搞混的操作。
第一个是enabled = false和stop()的区别。enabled = false之后,摄像头指示灯常常还亮着,因为采集器并没有停止,只是 WebRTC 栈在发送时用黑帧替代了真实画面。这对业务很有用,比如“临时不出镜但不想重新获取设备权限”,就可以把视频轨enabled置为 false,等会再置为 true 就能恢复。而stop()是真的把采集破坏了,之后 track 的 readyState 变成 ended,再想恢复只能重新getUserMedia。
第二个是clone()。videoTrack.clone()会返回一条新的 Track,它和原轨道共享一个采集源。这意味着你改 clone 的enabled不会影响原轨,但 clone 出来的轨道也占着一份发送带宽,如果两条轨都在 peer connection 里且都编码发送,码率是叠加的。我踩着过的实际坑是:有人想用 clone 做“本地预览 + 远端发送”双份,结果本地预览的分辨率和远端发送不同,结果把带宽吃了两倍。正确做法是本地预览用原轨,发送用原轨或 clone 后再按需要调整编码参数,而不是无脑 clone 后塞进 peer connection。
3.2 点对点连接:一个可直接运行的最小互连示例
搭建点对点连接时,最标准的流程是:本地采集 -> 创建 RTCPeerConnection ->addTrack-> 创建 Offer -> 交换 SDP -> ICE 交换 -> 对端ontrack接收。这里给一个用 Browser BroadcastChannel 做信道的简化全流程示例,适合大家在本地开两个页面测试。
发起方:
const pc = new RTCPeerConnection({ iceServers: [] }); const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localStream.getTracks().forEach(track => { // 关键:把两条轨道都挂到同一个 localStream 下 pc.addTrack(track, localStream); }); pc.onicecandidate = (e) => { if (e.candidate) { channel.postMessage({ type: 'candidate', candidate: e.candidate }); } }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); channel.postMessage({ type: 'offer', sdp: pc.localDescription });接收方:
const pc = new RTCPeerConnection({ iceServers: [] }); pc.ontrack = (event) => { const [remoteStream] = event.streams || []; if (remoteStream) { remoteVideo.srcObject = remoteStream; } console.log('track kind:', event.track.kind, 'readyState:', event.track.readyState); }; pc.onicecandidate = (e) => { if (e.candidate) { channel.postMessage({ type: 'candidate', candidate: e.candidate }); } }; async function onOffer(desc) { await pc.setRemoteDescription(desc); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); channel.postMessage({ type: 'answer', sdp: pc.localDescription }); }跑起来之后,接收方的ontrack通常触发两次,一次video一次audio,两次event.streams里都是同一个MediaStream(stream id 相同)。把这个现象打印到控制台看一眼,你对“Track 和 Stream 的关系”就会有肌肉记忆。
3.3 用 SDP 和 webrtc-internals 验证映射关系
代码写完了,怎么证明 Stream 和 Track 确实在底层被打包得明明白白?两个办法。
第一个,直接在控制台看localDescription.sdp:
const sdp = pc.localDescription.sdp; const msidLines = sdp.split('\n').filter(line => line.includes('a=msid')); console.log(msidLines.join('\n'));你会看到类似这样:
a=msid:8f5a5c1a-6f2d-4b3e-9a3d-123456789abc 14a9e5f2-2f42-4d15-9f31-abcdefabcdef a=msid:8f5a5c1a-6f2d-4b3e-9a3d-123456789abc 66a5f5b5-7a44-44aa-9d12-xxxxxxxxxxxx其中第一个 UUID 是 stream id,第二个是 track id。两条轨道共用一个 stream id,正说明它们被归到了同一个MediaStream。
第二个,打开chrome://webrtc-internals,找到对应连接的 outbound-rtp / inbound-rtp 统计项。里面能看到每个 RTP 流对应的trackId、mediaStreamId、kind等字段。把这里的mediaStreamId和 SDP 里的a=msid对应上,你就能把“RTP 包级的流”和“上层 API 的 Stream”彻底对应起来了。这里顺带说个经验:以后你要分析“是不是某条 Track 没发送数据”,先看 outbound-rtp 中该 track 对应的bytesSent有没有增长,再看framesEncoded有没有增长,基本能定位是采集端问题还是编码发送问题。
3.4 动态增减轨道:removeTrack 之后发生了什么
WebRTC 早期给人印象是“建立连接后就不能动了”,现在完全不是。你可以在任意时刻动态添加或移除轨道。移除使用pc.removeTrack(sender),这里sender是addTrack时的返回值,也可以从pc.getSenders()里取。
// 先添加视频轨 const videoSender = pc.addTrack(videoTrack, localStream); // 几秒后想移除视频轨 pc.removeTrack(videoSender);但要注意一个重要行为:removeTrack并不会让对端的track.readyState立刻变成ended,也不会触发track.onended。它只是把本地 SDP 中对应 m-line 的传输方向改成inactive,意思是“我不会再从这个轨道发数据了”。对端能感知到的通常是:
event.track的muted变为 true(仅部分实现)- 音频/视频长时间没有新数据,播放器画面冻结或黑屏
- 对端通过
getStats()看到该 track 对应 inbound-rtp 的bytesReceived停止增长
如果你想让对端明确知道“这条 Track 完了”,最稳妥的做法是走应用层信令通知,或者在 removeTrack 之后主动触发一次negotiationneeded让双方把 SDP 更新掉。否则只依赖媒体面判断,会有很大延迟和模糊性。
动态增加轨道则相反,pc.addTrack添加后会自动触发negotiationneeded,你只要在negotiationneeded事件里重新创建 Offer 走信令流程即可。这也是现代 WebRTC 应用的标准姿势:有多少轨道就 add 多少,随时可增减。
4. 链路容量估计如何影响 Track:码率、拥塞与自适应
4.1 先泼一盆冷水:很多“stream disconnected”报错和 WebRTC 无关
网络上搜 WebRTC 相关关键词时,很容易带出一堆“stream disconnected before completion: stream closed before response.completed”“stream disconnected before completion: upstream request failed”之类的报错。我在这里必须明确说一句:这类报错绝大多数出现在 HTTP/SSE 流式接口、某些 AI Agent 工具、代理网关的流式响应场景里,跟 WebRTC 的MediaStream没有一毛钱关系。
比如 Java 里的Stream、Node.js 里的Stream、硬件总线里的AXI-Stream,甚至安装 Altium Designer 时的stream write error,它们只是英文里同一个单词。你要是拿搜 Java Stream 八股文的经验去套 WebRTC 的 Track/Stream,只会越绕越远。真正确认报错归属的办法是看上下文——是不是跟RTCPeerConnection、getUserMedia、RTP这些词在一起。如果不是,别往 WebRTC 上靠。
4.2 WebRTC 侧的“流断开”到底由谁决定
在 WebRTC 里,真正能让所有 Track 都挂掉的事件是连接层失效:ICE 失败、DTLS 传输关闭、连接状态变为 failed 或 disconnected 且无法恢复。此时pc.onconnectionstatechange会给你信号,连接结束之后,远端相关的track才会陆续变成ended。
但网络抖动、丢包、短暂卡顿不会直接让 Track 变成 ended。连接还在,只是传输质量变差。所以你在做产品时,不要依赖track.readyState去判断对端是不是没网了,应该通过iceConnectionState、connectionState和getStats()里的currentRoundTripTime、packetsLost等指标综合判断。
这也就是为什么真正做音视频监控的人,会去读chrome://webrtc-internals和RTCOutboundRTPStreamStats,而不是只看“流是否断开”。
4.3 带宽估计器如何作用于每条 Track
链路容量估计是 WebRTC 拥塞控制的核心,和 Track 的编码码率直接相关。它的基本模型是:发送端为每个 RTP 包加一个transport sequence number(transport-cc 扩展),接收端记录每个包的到达时间,周期性地把延迟信息通过 RTCP Feedback 发回。发送端根据“延迟有没有持续增长”“丢包率是不是升高”判断网络是否拥塞,从而调整目标码率。
当估计出的带宽下降时,编码器会压低每条 Track 的编码码率:
- 优先降低编码目标码率(target bitrate)
- 帧率可能下降
- 极端情况下分辨率也会被降甚至丢帧
这一层和MediaStreamTrack是紧耦合的:Track 采集原始帧,编码器负责压缩,而带宽估计器指导编码器“往低了压”。所以你观察到的现象经常是:网络一抖动,画面先模糊、再卡顿、再黑屏,而Track.readyState始终是 live。有人以为是“流断了”,其实是码率被 BWE 砍下来了而已。
在这些机制里,“流”这个单词再次出现了许多次,但请你记住:底层 RTCP Feedback 里说的“流”,有时指 RTP 流(一条 Track 的媒体数据包序列),有时指拥塞控制流(一个连接内所有 RTP 流的集合)。它跟 API 上层的MediaStream容器不是同一个东西。搞清楚这个,你再读那些讨论带宽估计的文章就不会懵了。
5. 常见问题速查:Track/Stream 相关的坑与排查
5.1 为什么 track.clone() 看起来像同一条轨,行为却各自独立
问题:本地调了track.clone()给另一个 video 标签预览,修改 clone 的enabled=false后,原轨居然还能正常显示。
原因:clone()创建了新 Track 对象,但底层媒体源是同一个。enabled和stop()是轨道级别的开关,所以 clone 的开关不会直接作用于原轨。但底层采集源是否释放,由所有引用它的 Track 共同决定。如果原轨停了,clone 轨还在,采集源可能继续维持;反过来也一样。
建议:不要指望 clone 能“复制一份数据流”,它更适合做“同一采集源的多个出口”。需要独立设备的话,重新getUserMedia指定另外的deviceId。
5.2 移除 track 后,远端怎么看起来好像“没变化”
问题:pc.removeTrack(sender)之后,对端画面没有立刻消失,过了好几秒才黑屏。
原因:removeTrack只是 SDP 传输方向改为inactive,对端不会把 Track 状态改为 ended。播放器因为没有新帧,会延迟显示最后一帧或等待一段时间才黑屏。
建议:产品层面可以主动通过信令通知对端“这条通道关了”,让对端移除视频元素、显示占位图。也可以用RTCRtpReceiver的track.onmute事件配合,但不同浏览器行为有差异,信令最可靠。
5.3 同一个 track 加两次为什么报错
问题:想在同一个 RTCPeerConnection 里,把本地视频轨同时用于“视频主画面”和“小窗画中画”,于是调了两次addTrack(videoTrack, stream),结果抛InvalidTrackError。
原因:规范明确禁止同一个 Track 在同一个连接内重复发送。一个 Track 只能对应一个RTCRtpSender。
建议:如果只是本地展示画中画,直接用两个<video>元素引用同一个 Track 即可,不需要重复 add;如果确实需要在连接里发送两路画面(比如不同帧率/分辨率),请用track.clone()后把 clone 挂到新的 sender,或者改用 simulcast 机制,让编码器从同一轨道生成多路不同质量的 RTP 流。
5.4 “Stream 断开”这个报错到底该不该查 WebRTC
问题:项目里搜到stream disconnected before completion: stream closed before response.completed,以为和 WebRTC 有关,折腾了半天没结果。
原因:这类报错通常由流式 HTTP/SSE 客户端、自动化脚本或代理中间层产生,与 WebRTC 的MediaStream完全无关。它说的 stream 是“响应数据流”,不是音视频轨道容器。
建议:拿到任何报错先看它出现在哪一层。凡是和RTCPeerConnection、RTP、m-line、MediaStreamTrack无关的,直接按通用网络流式接口排查。不要把精力浪费在错位的关键词搜索上。
5.5 SDP 里能看到多路 Track,但对端 ontrack 只触发了一次
问题:本地addTrack了音频和视频两条轨道,但对端ontrack事件只触发了一次视频,音频一直没收到。
原因:大概率是 SDP 协商里音频 m-line 的编码协商失败,或者远端设置了audio标签但没有执行play(),导致音频状态异常,也可能本地只把视频轨道挂到了 stream 里,而音频轨道没有 addTrack 进连接。
建议:先用pc.getTransceivers()看每一条 m-line 的状态;再在接收端监听pc.onnegotiationneeded,确保收到新的 offer/answer 后及时 setRemoteDescription;最后检查ontrack里是否对audiotrack 做了 srcObject 绑定,并且记得调用audioEl.play()。
一件事做久了,会有一些属于自己的“手癖”。我现在调试任何 WebRTC 项目,第一步永远是搞清楚“我手里这货是 MediaStream 还是 MediaStreamTrack”。控制台打印一下Object.prototype.toString.call(obj),或者直接判断有没有getTracks方法,三秒钟就能定位问题的层次。这个习惯帮我少踩了无数次坑。另外,如果你在搜 WebRTC 问题时碰见一堆stream disconnected before completion之类的报错,记得先冷静,大概率那是 HTTP/SSE 流式接口在喊冤,不是 WebRTC 的 Track 与 Stream 出了问题。容器归容器,数据归数据,这个认知一旦建立,后面所有 WebRTC 的底层细节都不会再把你绕晕。