做 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 不是默认能力。

常见的做法有两种:

  1. 用原生小程序能力做同层渲染,引入支持 WebRTC 的 WebView 组件;
  2. 不让小程序直接拉 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 相关设计,先问自己三个问题:

  1. 这个对象是否直接代表采集源或 RTP 媒体数据?如果是,它是 Track。
  2. 这个对象是否只是用来把多个媒体归拢、组织和传输给 video.srcObject ?如果是,它是 Stream。
  3. 我要换设备、静音、停止采集,应该操作哪个?大概率是 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 变了?这两个答案,基本能定位八成问题。

Logo

邀请您加入社区

更多推荐