4. 媒体流与轨道

好,咱们正式开始动手了。

前面几章聊了WebRTC的架构、信令、网络穿透这些底层逻辑。说实话,光看理论容易犯困。这一章咱们直接打开浏览器,把摄像头和麦克风调出来,看看真正的媒体流长什么样。

你想想看,WebRTC最核心的能力是什么?就是让浏览器能直接采集、处理和传输音视频。而这一切的起点,就是 getUserMedia 这个API。我当年第一次调通它的时候,看到自己在屏幕上动起来,那种感觉——嗯,挺奇妙的。

本章核心脉络:从采集设备权限 → 拿到媒体流对象 → 操作音视频轨道 → 处理浏览器差异 → 最终跑通一个点对点Demo。每一步我都会把实际项目中踩过的坑指给你看。

浏览器端媒体流处理知识体系 getUserMedia API 权限请求与约束 MediaStream 对象 MediaStreamTrack video/audio 分辨率/帧率 getTracks() clone() enabled/stop() applyConstraints 浏览器兼容性处理 第一个视频通话Demo ⚠ 关键路径:权限获取 → 流对象管理 → 轨道控制 → 兼容适配 → 建立通话 每个环节都有浏览器差异,后面我会逐一说明

4.1 getUserMedia:打开媒体设备的第一把钥匙

navigator.mediaDevices.getUserMedia() 这个API,说白了就是向用户请求摄像头和麦克风权限的入口。它返回一个Promise,resolve之后给你一个 MediaStream 对象。

最基本的调用方式是这样的:

// 最简单的调用
async function startCamera() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    // 把流绑定到 video 元素上
    const video = document.getElementById('localVideo');
    video.srcObject = stream;
  } catch (err) {
    console.error('获取媒体设备失败:', err);
  }
}

嗯,这里要注意几个点。第一,getUserMedia 必须在用户手势(比如点击按钮)之后调用,否则浏览器会直接拒绝。我见过不少新手在页面加载时直接调用,结果Promise一直pending——说白了就是被浏览器拦截了。

第二,video: true 这种写法虽然简单,但实际项目中我很少这么用。为什么?因为浏览器会给你一个默认的分辨率,可能是320x240,也可能是1280x720,完全看浏览器心情。你想想看,如果做视频会议,320x240的画质能看吗?

我的习惯:永远显式指定分辨率约束。哪怕只是做个Demo,也把理想值写清楚,这样行为可预测。

4.2 约束(Constraints)的精细控制

getUserMedia 的约束参数其实很强大。它支持三种粒度:exact(精确值)、ideal(理想值)、min/max(范围)。

我给大家看一个我在项目中常用的配置:

const constraints = {
  video: {
    width: { ideal: 1280, min: 640, max: 1920 },
    height: { ideal: 720, min: 480, max: 1080 },
    frameRate: { ideal: 30, min: 15, max: 60 },
    facingMode: { ideal: 'user' }  // 前置摄像头
  },
  audio: {
    echoCancellation: { exact: true },
    noiseSuppression: { ideal: true },
    sampleRate: { ideal: 48000 }
  }
};

const stream = await navigator.mediaDevices.getUserMedia(constraints);

这里我解释一下为什么这么写:

  • width/height:给一个理想值,浏览器会尽量满足。如果设备不支持,它会降级到范围内的最低值。我曾经在老旧笔记本上遇到过摄像头只支持640x480的情况,加了 min 约束后至少保证画面不糊。
  • facingMode:移动端特别重要。user 是前置摄像头,environment 是后置。我踩过坑——在iOS Safari上如果不指定,默认可能给你后置摄像头,用户一脸懵。
  • echoCancellation:回声消除。这个我建议用 exact: true,因为如果设备不支持,你至少知道问题出在哪。否则开了回声消除但没效果,排查起来很头疼。

注意:exact 约束是强制的。如果设备不满足,Promise会reject。所以除非你确定设备支持,否则优先用 ideal。

4.3 MediaStream 与 MediaStreamTrack

拿到 MediaStream 之后,你会发现它其实是一个容器。里面装的是什么呢?是一个或多个 MediaStreamTrack 对象。

我习惯把 MediaStream 比作一个「音视频通道」,而 MediaStreamTrack 就是通道里的「独立车道」——一个车道跑视频,一个车道跑音频。你可以单独控制每个车道。

常用的操作:

// 获取所有轨道
const tracks = stream.getTracks();
// 获取视频轨道
const videoTracks = stream.getVideoTracks();
// 获取音频轨道
const audioTracks = stream.getAudioTracks();

// 停止某个轨道(比如关掉摄像头)
videoTracks[0].stop();

// 启用/禁用轨道(静音麦克风但不释放资源)
audioTracks[0].enabled = false;

// 克隆轨道(用于多路输出)
const clonedTrack = videoTracks[0].clone();

这里有个容易混淆的点:stop() 和 enabled = false 的区别。我刚开始做WebRTC时也搞混过——stop() 会彻底释放设备资源,之后这个轨道就不能再用了;而 enabled = false 只是暂时静音或黑屏,设备还在占用着。所以如果你只是临时关一下麦克风,用 enabled 就好;如果确定不再需要摄像头了,记得调 stop() 释放资源。

4.4 浏览器兼容性处理

说到兼容性,嗯,这里我得好好聊聊。WebRTC的API虽然已经标准化了,但不同浏览器的实现细节还是有差异的。我整理了一个表格,方便大家对照:

浏览器 getUserMedia 前缀 注意事项
Chrome 53+ 标准 navigator.mediaDevices.getUserMedia 需 HTTPS 或 localhost;支持大部分约束
Firefox 36+ 标准 API 对 facingMode 支持较好;部分旧版本需 moz 前缀
Safari 11+ 标准 API iOS 上必须用户手势触发;不支持 exact 约束的某些值
Edge (Chromium) 标准 API 与 Chrome 行为基本一致
旧版浏览器 navigator.getUserMedia(带前缀) 建议使用 adapter.js 库统一处理

我给大家一个兼容性处理的代码模板,这是我项目里一直在用的:

// 兼容性处理函数
function getMediaStream(constraints) {
  // 优先使用标准 API
  if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
    return navigator.mediaDevices.getUserMedia(constraints);
  }

  // 降级到旧版 API(带浏览器前缀)
  const getUserMedia = navigator.getUserMedia
    || navigator.webkitGetUserMedia
    || navigator.mozGetUserMedia
    || navigator.msGetUserMedia;

  if (!getUserMedia) {
    return Promise.reject(new Error('当前浏览器不支持 getUserMedia'));
  }

  // 将旧版回调转为 Promise
  return new Promise((resolve, reject) => {
    getUserMedia.call(navigator, constraints, resolve, reject);
  });
}

我的建议:虽然现在主流浏览器都支持标准API了,但如果你要兼容老旧设备(比如学校机房里的旧电脑),这个降级处理还是很有必要的。另外,adapter.js 这个库可以帮你省掉很多兼容性代码,我一般都会引入它。

4.5 第一个视频通话Demo

好了,理论说完了,咱们来写一个真正的Demo。这个Demo会打开本地摄像头,然后把画面显示在页面上。虽然只是单机版,但你已经能看到媒体流的全貌了。

HTML部分:

<!DOCTYPE html>
<html>
<head>
  <title>我的第一个视频通话Demo</title>
  <style>
    body { font-family: Arial, sans-serif; padding: 20px; background: #f0f8ff; }
    video { width: 640px; height: 480px; border: 2px solid #3498db; border-radius: 8px; }
    button { padding: 10px 20px; margin: 10px; background: #3498db; color: white; border: none; border-radius: 4px; cursor: pointer; }
    button:hover { background: #2980b9; }
    .status { margin: 10px 0; padding: 10px; background: #fff3cd; border-radius: 4px; }
  </style>
</head>
<body>
  <h2>本地摄像头预览</h2>
  <video id="localVideo" autoplay playsinline></video>
  <div>
    <button id="startBtn">打开摄像头</button>
    <button id="stopBtn">关闭摄像头</button>
  </div>
  <div id="status" class="status">点击按钮开始</div>

  <script src="script.js"></script>
</body>
</html>

JavaScript部分(script.js):

const video = document.getElementById('localVideo');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const statusDiv = document.getElementById('status');

let localStream = null;

// 打开摄像头
async function startCamera() {
  try {
    const constraints = {
      video: {
        width: { ideal: 1280 },
        height: { ideal: 720 },
        frameRate: { ideal: 30 }
      },
      audio: {
        echoCancellation: true,
        noiseSuppression: true
      }
    };

    localStream = await navigator.mediaDevices.getUserMedia(constraints);
    video.srcObject = localStream;
    statusDiv.textContent = '✅ 摄像头已打开';
    statusDiv.style.background = '#d4edda';
  } catch (err) {
    statusDiv.textContent = '❌ 打开失败: ' + err.message;
    statusDiv.style.background = '#f8d7da';
    console.error('getUserMedia error:', err);
  }
}

// 关闭摄像头
function stopCamera() {
  if (localStream) {
    // 停止所有轨道
    localStream.getTracks().forEach(track => track.stop());
    video.srcObject = null;
    localStream = null;
    statusDiv.textContent = '🛑 摄像头已关闭';
    statusDiv.style.background = '#fff3cd';
  }
}

// 事件绑定
startBtn.addEventListener('click', startCamera);
stopBtn.addEventListener('click', stopCamera);

这个Demo虽然简单,但包含了几个关键点:

  • autoplay playsinline:这两个属性必须加。iOS Safari上不加 playsinline 的话,视频会全屏播放,用户体验很差。
  • 轨道释放:关闭摄像头时一定要调 track.stop(),否则摄像头指示灯一直亮着,用户会以为被偷拍了。
  • 错误处理:用户拒绝权限、设备不存在、约束不满足——这些情况都要处理。我见过线上产品因为没处理 NotAllowedError 导致页面白屏的。

我曾经踩过的坑:有一次在Chrome上测试,发现关闭摄像头后再打开,画面卡住了。排查了半天,发现是 srcObject 没有置为null,导致旧流和新流冲突。所以关闭时一定要 video.srcObject = null。

4.6 小结:从采集到预览,你已迈出第一步

这一章我们走通了浏览器端媒体流处理的完整链路:从 getUserMedia 的权限请求,到约束的精细控制,再到 MediaStream 和 MediaStreamTrack 的操作,最后用兼容性代码跑通了一个Demo。

说实话,能走到这一步,你已经掌握了WebRTC最基础也最核心的能力——采集媒体流。后面的信令、网络传输、编解码,都是建立在这个基础之上的。

嗯,我建议你把Demo跑起来,然后试着改改约束参数,看看不同分辨率、帧率下的效果。动手试过的东西,印象才深。