WebRTC中Track与Stream:从RTP传输到高频操作一次讲透
做 WebRTC 的人,几乎都会在某个阶段被两个名字搞晕:Track 和 Stream。我第一次做一对一视频时,就把 MediaStream 的 id 打印出来,在远端去对,结果怎么都对不上。后来才明白,Stream 本质上只是本地的一个容器标签,真正一路跟着 RTP 包走到对端的,是 Track,而不是 Stream。这篇分享就把 WebRTC 里 Track 和 Stream 的本质、传输链路上的表现、以及日常编码里的高频操作一次讲透。适合刚接触 WebRTC、被 SDP 和回调搞懵的人,也适合已经在写推流和拉流代码、却总觉得隔了一层纱的人。
1. 先把两个概念摆到桌面上:MediaStream 是容器,MediaStreamTrack 是源头
1.1 从 getUserMedia 的返回结果说起
很多人第一次接触这两个概念,是从
navigator.mediaDevices.getUserMedia()
开始的。这个 API 返回的是一个
MediaStream
对象,名字叫 Stream,于是大家自然以为:我拿到了一条视频流。
实际上,
getUserMedia
返回的 MediaStream 不携带任何视频数据,它只是一个集合。真正的数据源头是里面的
MediaStreamTrack
。
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
const videoTracks = stream.getVideoTracks();
const audioTracks = stream.getAudioTracks();
console.log(videoTracks[0].kind); // 'video'
console.log(audioTracks[0].kind); // 'audio'
摄像头产生一路视频 Track,麦克风产生一路音频 Track。MediaStream 的作用,是把这些 Track 捆在一起,给上层一个统一的操作入口。如果你把 Stream 理解成一个“媒体分组”,把 Track 理解成“实际的采集源”,很多困惑会少一半。
1.2 Track 有状态,Stream 没有状态
MediaStream 的状态很少,基本就是一个
active
属性,表示里面是否还有正在运行的 Track。Track 的状态和事件就丰富多了,因为它是真正和数据源打交道的对象。
| 能力 | MediaStreamTrack | MediaStream |
|---|---|---|
| 表示数据源头 | 是,直接关联摄像头、麦克风、屏幕 | 不是,只是聚合 |
| 开关静音 |
enabled = true/false
| 没有直接开关 |
| 停止采集 |
track.stop()
| 没有直接停止方法 |
| 状态 |
readyState
: live / ended
|
active
: true / false
|
| 关联事件 |
ended
、
mute
、
unmute
|
addtrack
、
removetrack
|
| 复制 |
track.clone()
| 只能 addTrack/removeTrack |
这个区别在面试和实际排错里都能看到。有人问“怎么停止整个 Stream”,标准答案不是找一个
stream.stop()
,而是遍历
stream.getTracks()
逐个调用
stop()
。因为真正的采集资源被 Track 持有,Stream 不掌握资源。
1.3 真正在网络上传输的是 RTP 流,不是 MediaStream
这里要强调一件反直觉的事:MediaStream 并不会被“整体发送”。浏览器在建立 WebRTC 连接时,发送的最小媒体单元是 MediaStreamTrack,在网络上表现为 RTP 流。
所以你经常看到有人画数据流,把它们画成一条从本地 MediaStream 到远端 MediaStream 的箭头,这是逻辑上图方便。更严谨的理解是:
摄像头 -> MediaStreamTrack(video)
麦克风 -> MediaStreamTrack(audio)
-> RTCRtpSender
-> 网络 RTP 包
-> RTCRtpReceiver
-> 远端 MediaStreamTrack
-> 放入远端 MediaStream
-> video.srcObject
视频 Track 和音频 Track 是两条独立的 RTP 流,各有各的 SSRC、PT、序号和时间戳。MediaStream 的作用更像打包盒上的标签,方便浏览器在接收端把属于同一次通话的多个 Track 重新归拢到一起。
2. 把传输链路拆开:SDP 与 MSID 是 Track 和 Stream 的连接点
2.1 RTP 包里为什么找不到 Stream 的痕迹
RTP 包头里没有 MediaStream id,也没有 MediaStreamTrack id。RTP 包头主要带的是 SSRC、序号、时间戳等传输信息。既然传输层不认识 Stream,那接收端是怎么恢复出
event.streams[0]
的?
答案是 SDP。在 Offer/Answer 协商阶段,双方把媒体信息用 SDP 描述清楚,其中就有
a=msid
这样的属性。
a=msid:{stream_id} {track_id}
a=ssrc:{ssrc} msid:{stream_id} {track_id}
采集端的 MediaStream 有一个 id,MediaStreamTrack 也有一个 id。SDP 里的
msid
把这两层信息都带上。对端收到 SDP 后,就会根据这些 id 重建一个 MediaStream,并把对应的 Track 塞进去。你也可以在
ontrack
事件里看到:
pc.ontrack = (event) => {
console.log(event.track.id);
console.log(event.streams[0].id);
video.srcObject = event.streams[0];
};
我在调试时经常用这个事件做验证:先看
event.track
是否为 null,再看
event.streams.length
是否为 0。这两个值基本能判断 SDP 协商时有没有正确携带 MSID。
2.2 ontrack 和 event.streams 的对应关系
老版本 WebRTC 里有
onaddstream
,新版已经废弃,取而代之的是
ontrack
。这个变化本身就说明了设计思路的转变:以前用“流”作为回调对象,现在用“轨”作为回调对象。
ontrack
事件里能拿到
track
、
streams
、
transceiver
、
receiver
。其中
streams
是一个数组,因为一个 Track 可以同时属于多个 MediaStream。但在绝大多数场景里,它的长度是 0 或 1。
pc.addTrack(videoTrack, stream);
这样添加时,远端
ontrack
里的
streams[0]
会对应到本地的
stream
。如果你只写:
pc.addTrack(videoTrack);
远端也能收到 Track,但
event.streams
是空的。很多人在远端一直拿不到 stream,不一定是没接收成功,而是发送端本来就没把 Track 归入任何 Stream。
2.3 Simulcast 里只有一路 Track,但有多路编码
Simulcast(联播)是最能体现 Track 和 Stream 区别的场景之一。开启 Simulcast 后,本地一个摄像头 Track 会产生多路不同分辨率的 RTP 编码,比如 1080p、720p、360p,但在 Track 层面还是同一个 MediaStreamTrack。
SDP 里会出现多个
a=simulcast
相关的编码,但不会出现多个 MediaStreamTrack。接收端根据网络状况选择其中一路编码来解码。如果你把 Track 当成“一路视频”,Simulcast 会很难理解;把它当成“一个媒体源”,就顺了:同一个媒体源,只是被编码多次。
3. 高频操作背后的本质:replaceTrack、mute、removeTrack
3.1 换摄像头:替换的是 Track,不是重建 Stream
很多业务需要切换前后摄像头。新手最容易犯的错是重新调用
getUserMedia
拿到一个新 Stream,然后重新
addTrack
、重新协商。
其实不用。标准做法是替换 Track:
const newStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
const newVideoTrack = newStream.getVideoTracks()[0];
const sender = pc.getSenders().find((s) => s.track?.kind === 'video');
await sender.replaceTrack(newVideoTrack);
replaceTrack
的关键在于:RTCPeerConnection 里的发送器(RTCRtpSender)还在,协商好的传输通道还在,变的只是发送器里挂载的 Track。这种方式可以不重新协商 SDP,很多场景下切换非常顺滑。
替换后还要记得把旧 Track 停掉:
oldVideoTrack.stop();
否则摄像头指示灯会一直亮着。这是移动端常见的耗电和隐私问题。
3.2 静音不是 stop,stop 不是 remove
音频静音的正确做法是:
audioTrack.enabled = false;
enabled = false
后,发送端会继续发送 RTP 包,但内容是静音帧。接收端能感知到 Track 还在,只是没有有效音频。好处是不用重新协商,也不会有“对端已挂断”的错觉。
track.stop()
则完全不同。它直接结束采集源,Track 的
readyState
变为
ended
,摄像头或麦克风被释放。如果你只是想临时静音,千万别 stop。
removeTrack
更进一步,它是把 Track 从 RTCPeerConnection 里移除,通常需要重新协商:
const sender = pc.getSenders().find((s) => s.track === videoTrack);
pc.removeTrack(sender);
意味着这条路媒体不再发送,对应 SDP 里的媒体行可能变成
inactive
或被移除。三者的区别,用生活化的话说:
enabled = false
是“闭嘴”,
stop()
是“让嘴离开桌子”,
removeTrack
是“把这个人从会议室名单里划掉”。
3.3 移除 Track 时远端看到什么
removeTrack
后,远端可能看到以下情况:
-
removetrack事件在对应的 MediaStream 上触发; -
Track 的
readyState变成ended; - 视频元素上最后一帧可能保留,也可能变黑,取决于浏览器实现;
- 如果没有正确处理,视频元素里会出现“冻住的最后一帧”。
我一般会监听
track.onended
,在这个回调里把
video.srcObject
里的对应 track 清掉,或者干脆重新赋值新的 Stream。不要假定所有浏览器表现一致,线上环境需要自己验证。
4. 落到真实项目里:Vue、RTSP 转 WebRTC、小程序播放
4.1 Vue 项目里的采集封装与生命周期
在 Vue 里使用 WebRTC,最常见的问题是组件销毁后没有停掉摄像头和麦克风。一个简单的封装可以这样写:
let localStream = null;
export async function startLocalStream(constraints) {
localStream = await navigator.mediaDevices.getUserMedia(constraints);
return localStream;
}
export function stopLocalStream() {
localStream?.getTracks().forEach((track) => track.stop());
localStream = null;
}
在组件里:
onMounted(async () => {
const stream = await startLocalStream({ video: true, audio: true });
videoEl.value.srcObject = stream;
});
onBeforeUnmount(() => {
stopLocalStream();
});
注意别把
stream
直接放进 Vue 的响应式系统里。MediaStream 内部有大量复杂字段,响应式代理会带来性能损耗和诡异的更新问题。只需要维护一个普通变量,或者用
shallowRef
。
如果你要做“摄像头开关”,操作的是 Track,不是 Stream:
const videoTrack = localStream.getVideoTracks()[0];
videoTrack.enabled = !videoTrack.enabled;
这样才能做到不重新协商、不打断通话,体验上最接近微信视频会议里的开关。
4.2 RTSP 转 WebRTC 时的轨道映射
很多团队做低延迟监控时,会把 RTSP 视频流转成 WebRTC 在浏览器里播放,这也是热搜里“rtsp转webrtc”的常见需求。RTSP 源一般包含一路视频 Track 和一路音频 Track。在转推服务里,核心动作就是把这几个 Track 分别映射成 WebRTC 的 RTP Sender。
服务端通常不是直接使用浏览器 API 里的 MediaStreamTrack,但思想一致:
RTSP 视频通道 -> 解码/转封装 -> 视频 RTP Sender
RTSP 音频通道 -> 解码/转封装 -> 音频 RTP Sender
如果你用集成方案如 mediamtx、SRS 或自定义的 LiveKit 部署,要注意它们的命名和配置里也会区分 Track 和 Stream。配置错误时经常出现“有画面没声音”,或者“第一秒有画面后黑屏”,多数不是推流端坏了,而是音视频 Track 没有被组合到同一个 Stream 里,播放端拿不到完整的媒体集合。
4.3 小程序播放 WebRTC 的能力边界
热搜里还有“webrtc在小程序播放”的问题。微信公众号网页里的 WebView 在 iOS 上普遍支持 WebRTC,但在小程序原生环境里,
RTCPeerConnection
、
getUserMedia
这类 API 不是默认能力。
常见的做法有两种:
- 用原生小程序能力做同层渲染,引入支持 WebRTC 的 WebView 组件;
-
不让小程序直接拉 WebRTC,而是由服务端把 WebRTC 转成小程序
live-player能拉的协议。
这里最容易踩的坑,是把 Track、Stream 的概念照搬到小程序组件上。小程序里的
live-player
通常只管播放,不暴露 Track 级别的 API,你只需要把一个可播放地址丢给它。所以做架构选型时,先确认客户端能力边界,再决定服务端要不要做协议转换。
5. 排错实录:画面不显示时,先查 Track 还是先查 Stream
5.1 排查顺序与症状对照表
我现在的排查习惯是:先 Track,再 Stream,最后 SDP。
| 症状 | 优先检查对象 | 常见原因 |
|---|---|---|
| 本地摄像头预览黑屏 | Track |
getUserMedia
失败,或
video.srcObject
未赋值
|
| 远端黑屏但连接建立 | Track |
发送端没有成功采集,或
replaceTrack
传了空 Track
|
| 远端能收到 Track 但没声音 | Track |
音频 Track 被
enabled=false
,或没放到 SDP 对应 m-line
|
| 远端拿不到 stream | Stream |
addTrack
时没传
stream
参数
|
| 视频一阵一阵黑 | SDP | Simulcast 只有一层编码,带宽估计波动 |
| 对端看到最后一帧卡住 | Track |
本地
removeTrack
,远端没有清理视频元素
|
5.2 坑一:addTrack 没传 stream 导致 streams 为空
有一次联调,对方后台一直说“我已经收到流了,但没有画面”。我让他打印
ontrack
事件里的
event.streams
,结果是空数组。
问题出在他封装的函数只做了:
pc.addTrack(track);
改成:
pc.addTrack(track, localStream);
后,远端
event.streams
就有值了。这个坑很隐蔽,因为 SDP 协商仍然成功,RTP 包也在传,只有 msid 相关的信息缺失,导致远端无法把 Track 归入 Stream。
5.3 坑二:replaceTrack 成功但画面没变
另一个高频坑是
replaceTrack
执行成功,控制台没有报错,但远端画面还是旧的。
先检查是不是同一个
RTCRtpSender
。如果你用
pc.getSenders()
找到的 sender 对应的是音频 Track,然后拿视频 Track 去替换,返回 Promise 也会成功,但类型不匹配,浏览器可能忽略或报错。
再检查 SDP 是否需要重新协商。虽然很多浏览器支持
replaceTrack
不重新协商,但如果你切换的新 Track 有完全不同的编解码能力,某些场景下还是会出现不能解码的情况。稳妥做法是替换后等一帧确认,必要时重新发起一次 Offer。
5.4 “stream disconnected before completion”和 WebRTC 没关系
很多人在看日志时,会看到“stream disconnected before completion: stream closed before response.completed”这类报错。它通常来自 SSE、HTTP 流式接口或一些云服务的 SDK,不是 WebRTC 的 Track/Stream 报错。
这里的 stream 是“字节流”或“事件流”,和 WebRTC 里的 MediaStream 完全是两个概念。如果是在调大模型接口或后端流式接口时遇到,应该查 HTTP 连接、超时、代理设置,而不是去查
ontrack
。做 WebRTC 排错时,看到错误先分清是传输层、信令层还是媒体层,不要被同一个英文单词带偏。
6. 一句话总结:Track 是数据,Stream 是标签
6.1 三个问题帮你快速判断
以后遇到 WebRTC 相关设计,先问自己三个问题:
- 这个对象是否直接代表采集源或 RTP 媒体数据?如果是,它是 Track。
-
这个对象是否只是用来把多个媒体归拢、组织和传输给
video.srcObject?如果是,它是 Stream。 - 我要换设备、静音、停止采集,应该操作哪个?大概率是 Track。
6.2 团队协作时建议统一的命名约定
WebRTC 代码里,变量命名很容易混乱。有人把
stream
命名为
media
,有人把
track
命名为
stream
,联调时非常痛苦。
我建议在团队里约定:
-
本地采集对象叫
localStream; -
远端媒体集合叫
remoteStream; -
视频轨叫
videoTrack; -
音频轨叫
audioTrack; -
sender、receiver、transceiver不要混用。
看起来只是命名问题,但当你同时跑多路视频、屏幕共享、直播推流时,清晰的命名能省下一半 debug 时间。理解了 Track 是数据源头、Stream 是逻辑标签之后,再看 WebRTC 很多设计都会通顺。遇到问题,动手打印一行
track.readyState
和
stream.getTracks()
,往往比看一堆文档更有用。
我现在的习惯是,接到任何 WebRTC 反馈,先问对方:你说的“流没了”,是指
track.readyState
变了,还是
stream.active
变了?这两个答案,基本能定位八成问题。
更多推荐

所有评论(0)