WebRTC 标准概括介绍了两种不同的技术:媒体捕获设备和点对点连接。
媒体捕获设备包括摄像机和麦克风,还包括屏幕捕获设备。对于摄像头和麦克风,我们使用
navigator.mediaDevices.getUserMedia() 来捕获 MediaStreams。对于屏幕录制,使用 navigator.mediaDevices.getDisplayMedia()。点对点连接由
RTCPeerConnection 接口处理。这是在 WebRTC 中两个对等方之间建立和控制连接的中心点。媒体设备
在 Web 开发中,WebRTC 标准提供了一组 API,用于访问连接到计算机或智能手机上的摄像头和麦克风。这些设备通常被称为媒体设备,可通过 JavaScript 进行访问,具体由
navigator.mediaDevices 对象提供支持,该对象实现了 MediaDevices 接口。借助该对象,开发者可以枚举当前已连接的媒体设备、监听设备的连接或断开事件,以及打开指定设备并获取对应的媒体流(详见下文)。最常见的用法是调用
getUserMedia() 方法,该方法会返回一个 Promise。当请求的媒体设备满足指定条件时,Promise 会解析为一个 MediaStream 对象。getUserMedia() 接受一个 MediaStreamConstraints 对象,用于描述所需的媒体类型及相关约束条件。例如,如果需要直接打开系统默认的麦克风和摄像头,可以按如下方式调用该方法。const openMediaDevices = async (constraints) => { return await navigator.mediaDevices.getUserMedia(constraints); } try { const stream = openMediaDevices({'video':true,'audio':true}); console.log('Got MediaStream:', stream); } catch(error) { console.error('Error accessing media devices.', error); }
调用
getUserMedia() 会触发权限请求。如果用户 接受权限后,promise 会通过包含以下内容的 MediaStream 进行解析: 一个视频和一个音轨。如果权限被拒绝, 抛出 PermissionDeniedError。如果没有匹配设备连接,系统会抛出 NotFoundError。查询媒体设备
在更复杂的应用中,我们很可能需要检查所有连接摄像头和麦克风。这可以通过调用函数
enumerateDevices() 来实现。这将返回一个 promise,该 promise 可解析为 MediaDevicesInfo 数组,该数组描述了每台已知的媒体设备,我们可以用它来向用户展示,用户会选择自己喜欢的一个媒体设备,每个 MediaDevicesInfo 都包含一个名为kind,值为 audioinput、audiooutput 或 videoinput,表示媒体设备的类型。async function getConnectedDevices(type) { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.filter(device => device.kind === type) } const videoCameras = getConnectedDevices('videoinput'); console.log('Cameras found:', videoCameras);
监听设备更改
大多数计算机都支持在运行时接入或移除各种外部设备,例如通过 USB 连接的摄像头、蓝牙耳机,或外接扬声器。为了正确支持这类动态变化,Web 应用应监听媒体设备的变更情况。可以通过
navigator.mediaDevices 对象监听 devicechange 事件,以在设备发生变化时及时作出响应。// Updates the select element with the provided set of cameras function updateCameraList(cameras) { const listElement = document.querySelector('select#availableCameras'); listElement.innerHTML = ''; cameras.map(camera => { const cameraOption = document.createElement('option'); cameraOption.label = camera.label; cameraOption.value = camera.deviceId; }).forEach(cameraOption => listElement.add(cameraOption)); } // Fetch an array of devices of a certain type async function getConnectedDevices(type) { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.filter(device => device.kind === type) } // Get the initial set of cameras connected const videoCameras = getConnectedDevices('videoinput'); updateCameraList(videoCameras); // Listen for changes to media devices and update the list accordingly navigator.mediaDevices.addEventListener('devicechange', event => { const newCameraList = getConnectedDevices('video'); updateCameraList(newCameraList); });
媒体限制条件
constraints 对象作为
getUserMedia() 的参数,必须符合 MediaStreamConstraints 接口,用于指定要获取的媒体设备及其使用要求。这些约束条件既可以是宽泛的(例如是否启用音频和/或视频),也可以是非常具体的(例如期望的最小分辨率,或指定某一个摄像头的 deviceId)。通常建议先通过
getUserMedia() 或 enumerateDevices() 获取当前可用的媒体设备列表,然后再使用包含 deviceId 的约束条件,精确匹配所需的设备。如果设备支持,浏览器还会根据这些约束对设备进行相应配置。例如,可以在麦克风上启用回声消除功能,或者为摄像头设置期望的宽度、高度以及与镜头相关的最小分辨率要求。async function getConnectedDevices(type) { const devices = await navigator.mediaDevices.enumerateDevices(); return devices.filter(device => device.kind === type) } // Open camera with at least minWidth and minHeight capabilities async function openCamera(cameraId, minWidth, minHeight) { const constraints = { 'audio': {'echoCancellation': true}, 'video': { 'deviceId': cameraId, 'width': {'min': minWidth}, 'height': {'min': minHeight} } } return await navigator.mediaDevices.getUserMedia(constraints); } const cameras = getConnectedDevices('videoinput'); if (cameras && cameras.length > 0) { // Open first available video camera with a resolution of 1280x720 pixels const stream = openCamera(cameras[0].deviceId, 1280, 720); }
本地播放
打开媒体设备且找到可用的
MediaStream 后, 可以将其分配给视频或音频元素,以便在本地播放视频流。async function playVideoFromCamera() { try { const constraints = {'video': true, 'audio': true}; const stream = await navigator.mediaDevices.getUserMedia(constraints); const videoElement = document.querySelector('video#localVideo'); videoElement.srcObject = stream; } catch(error) { console.error('Error opening video camera.', error); } }
与
getUserMedia() 搭配使用的视频元素,通常需要在 HTML 中设置 autoplay 和 playsinline 属性。autoplay 用于在新的视频流绑定到该元素后自动开始播放;playsinline 则允许视频以内嵌方式播放,而不是只能以全屏模式显示(在移动端尤为重要)。此外,对于直播场景,通常建议将
controls 设置为 false,以隐藏播放控制栏,除非业务需求允许用户暂停或控制直播内容。<html> <head><title>Local video playback</title></head> <body> <video id="localVideo" autoplay playsinline controls="false"/> </body> </html>
媒体捕获和约束
WebRTC 的媒体部分主要介绍了如何访问用于采集音频和视频的硬件设备(如摄像头和麦克风),以及媒体流的基本工作机制。此外,还涵盖了媒体的呈现方式,包括应用如何进行屏幕捕获并显示相关内容。
媒体设备
浏览器所支持的所有摄像头和麦克风都可以通过
navigator.mediaDevices 对象进行访问和管理。应用不仅可以获取当前已连接的媒体设备列表,还可以监听设备的变化情况。由于许多摄像头和麦克风是通过 USB 等方式连接的,可能在应用运行期间被动态接入或移除,因此媒体设备的状态随时都可能发生变化。基于这一点,建议应用注册设备变更事件,以便在设备发生变化时能够及时、正确地进行处理。限制条件
在访问媒体设备时,建议尽可能提供详细的约束条件。虽然可以通过较为简单的约束直接打开系统默认的摄像头和麦克风,但这种方式往往无法获得最符合应用需求的媒体流质量。
具体的约束条件通过
MediaTrackConstraint 对象进行定义,音频和视频分别对应各自的约束对象。该对象中的属性可以是 ConstraintLong、ConstraintBoolean、ConstraintDouble 或 ConstraintDOMString 类型。这些约束既可以是具体的取值(例如数值、布尔值或字符串),也可以是表示取值范围的对象(如 LongRange 或 DoubleRange,包含最小值和最大值),还可以使用带有 ideal 或 exact 属性的约束形式。对于指定为具体值的约束,浏览器会尝试选择最接近该值的配置;对于范围约束,系统会在给定范围内选择最优的取值;而当使用
exact 约束时,只有完全满足该条件的媒体流才会被返回。直接使用数值约束
// Camera with a resolution as close to 640x480 as possible { "video": { "width": 640, "height": 480 } }
RANGE
// Camera with a resolution in the range 640x480 to 1024x768 { "video": { "width": { "min": 640, "max": 1024 }, "height": { "min": 480, "max": 768 } } }
EXACT
// Camera with the exact resolution of 1024x768 { "video": { "width": { "exact": 1024 }, "height": { "exact": 768 } } }
如果需要确认媒体流中某个轨道的实际配置,可以调用
MediaStreamTrack.getSettings() 方法,该方法会返回当前已生效的 MediaTrackSettings,用于反映该轨道当前使用的具体参数。此外,还可以通过对轨道调用
applyConstraints() 方法来更新已打开的媒体设备所应用的约束条件。通过这种方式,应用能够在不关闭现有媒体流的情况下,动态地重新配置媒体设备的参数。对等链接
Peer Connections(点对点连接) 是 WebRTC 规范中的一个核心部分,主要用于在不同计算机上的两个应用之间建立连接,并通过点对点(P2P)协议进行通信。对等端之间的通信内容可以是视频、音频,或者任意的二进制数据(前提是客户端支持 RTCDataChannel API)。
为了确定两个对等端如何建立连接,双方客户端都需要提供 ICE 服务器配置。ICE 服务器可以是 STUN 服务器 或 TURN 服务器,其作用是为每个客户端生成并提供 ICE 候选地址(ICE candidates),这些候选信息随后会被传递给远端对等端。
这种在对等端之间传递 ICE 候选信息的过程,通常被称为 信令(signaling)。
信令
WebRTC 规范中包含了用于与 ICE(Internet Connectivity Establishment)服务器 通信的相关 API,但并未包含信令(signaling)机制本身。信令的作用是让两个对等端能够交换彼此的连接信息,从而知道应当如何建立连接。
通常,这一问题会通过一个基于 HTTP 的常规 Web API 来解决,例如 REST 服务 或其他 RPC 机制。Web 应用可以在真正建立点对点连接之前,通过该信令服务中继和交换所需的连接信息。
下面的代码示例展示了如何使用一个虚构的信令服务来异步地发送和接收消息。在本指南后续的示例中,如有需要,将会使用到该信令服务。
// Set up an asynchronous communication channel that will be // used during the peer connection setup const signalingChannel = new SignalingChannel(remoteClientId); signalingChannel.addEventListener('message', message => { // New message from remote client received }); // Send an asynchronous message to the remote client signalingChannel.send('Hello!');
信令的实现方式多种多样,WebRTC 规范并未规定或推荐任何特定的信令实现方案。
发起点对点连接
每一个点对点连接都由一个
RTCPeerConnection 对象来管理。该类的构造函数接收一个 RTCConfiguration 对象作为参数,用于定义点对点连接的初始化方式,其中应包含所使用的 ICE 服务器 相关信息。在创建
RTCPeerConnection 之后,需要根据当前端是呼叫方还是接收方,生成一个 SDP Offer 或 SDP Answer。当 SDP Offer 或 Answer 创建完成后,必须通过另一条通信通道将其发送给远端对等端。将 SDP 对象传递给远端对等端的过程称为 信令(signaling),这一部分并不属于 WebRTC 规范的内容。在呼叫方发起点对点连接时,首先创建一个
RTCPeerConnection 对象,然后调用 createOffer() 生成一个 RTCSessionDescription 对象。该会话描述会通过 setLocalDescription() 方法设置为本地描述,随后通过信令通道发送给接收方。同时,还需要在信令通道上注册监听器,用于接收来自接收方的 SDP Answer,以完成连接协商流程。async function makeCall() { const configuration = {'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}]} const peerConnection = new RTCPeerConnection(configuration); signalingChannel.addEventListener('message', async message => { if (message.answer) { const remoteDesc = new RTCSessionDescription(message.answer); await peerConnection.setRemoteDescription(remoteDesc); } }); const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); signalingChannel.send({'offer': offer}); }
在接收方一侧,我们会在收到传入的 SDP Offer 之后,才创建
RTCPeerConnection 实例。创建完成后,首先通过 setRemoteDescription() 方法设置收到的 Offer。接下来,调用
createAnswer() 生成针对该 Offer 的 SDP Answer。生成的 Answer 会通过 setLocalDescription() 方法设置为本地描述,随后再通过信令服务器发送回呼叫方。const peerConnection = new RTCPeerConnection(configuration); signalingChannel.addEventListener('message', async message => { if (message.offer) { peerConnection.setRemoteDescription(new RTCSessionDescription(message.offer)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); signalingChannel.send({'answer': answer}); } });
当两个对等端都设置完成本地和远端的会话描述之后,它们就已经了解了对方所支持的能力。但这并不意味着对等端之间的连接已经建立完成。要真正建立连接,还需要在每一端收集 ICE 候选地址(ICE candidates),并通过信令通道将这些候选信息传递给另一端。
ICE candidates
在两个对等端能够通过 WebRTC 进行通信之前,它们需要先交换连接相关的信息。由于网络环境会受到多种因素的影响,通常需要借助一个外部服务来发现与对等端建立连接的可行候选地址。这个服务称为 ICE(Internet Connectivity Establishment),它通过 STUN 服务器 或 TURN 服务器 来实现。
STUN(Session Traversal Utilities for NAT) 是一种用于穿越 NAT 的工具,在大多数 WebRTC 应用中通常以间接方式使用。
TURN(Traversal Using Relay NAT) 是一种更高级的解决方案,它在 STUN 协议的基础上进行了扩展。大多数商业级的 WebRTC 服务都会使用 TURN 服务器来协助对等端之间建立连接。WebRTC API 对 STUN 和 TURN 都提供了直接支持,并将它们统一归入 Internet Connectivity Establishment(ICE) 这一完整概念之下。在创建 WebRTC 连接时,通常会在 RTCPeerConnection 对象的配置中指定一个或多个 ICE 服务器。
Trickle ICE
当创建 RTCPeerConnection 对象后,其底层框架会使用所配置的 ICE 服务器来收集用于建立连接的候选地址(ICE candidates)。RTCPeerConnection 上的 icegatheringstatechange 事件用于指示 ICE 候选收集所处的状态,包括 new、gathering 和 complete。
虽然对等端也可以选择等待 ICE 候选收集全部完成之后再进行下一步,但在实际应用中,通常采用 Trickle ICE 技术会更加高效。该方式会在每一个 ICE 候选被发现时,立即将其发送给远端对等端,从而显著缩短点对点连接的建立时间,使视频通话能够更快开始、减少整体延迟。
要收集 ICE 候选地址,只需为 icecandidate 事件添加监听器即可。该监听器接收到的 RTCPeerConnectionIceEvent 对象中包含一个 candidate 属性,表示新生成的 ICE 候选地址,应当通过信令通道发送给远端对等端(参见信令部分)。
// Listen for local ICE candidates on the local RTCPeerConnection peerConnection.addEventListener('icecandidate', event => { if (event.candidate) { signalingChannel.send({'new-ice-candidate': event.candidate}); } }); // Listen for remote ICE candidates and add them to the local RTCPeerConnection signalingChannel.addEventListener('message', async message => { if (message.iceCandidate) { try { await peerConnection.addIceCandidate(message.iceCandidate); } catch (e) { console.error('Error adding received ice candidate', e); } } });
连接建立
当开始接收到 ICE 候选地址后,点对点连接的状态最终应当会切换为 已连接(connected)。为了检测这一状态变化,可以在
RTCPeerConnection 对象上添加监听器,用于监听 connectionstatechange 事件。
// Listen for connectionstatechange on the local RTCPeerConnection peerConnection.addEventListener('connectionstatechange', event => { if (peerConnection.connectionState === 'connected') { // Peers connected! } });
远端媒体流
当
RTCPeerConnection 与远端对等端成功建立连接后,双方就可以在它们之间传输音频和视频数据。此时,需要将通过 getUserMedia() 获取到的媒体流接入到 RTCPeerConnection 中。一个媒体流至少包含一个媒体轨道(MediaTrack),在需要将媒体发送给远端对等端时,这些轨道会被逐个添加到
RTCPeerConnection 中,以实现媒体数据的传输。const localStream = await getUserMedia({video: true, audio: true}); const peerConnection = new RTCPeerConnection(iceConfig); localStream.getTracks().forEach(track => { peerConnection.addTrack(track, localStream); });
即使在
RTCPeerConnection 尚未与远端对等端建立连接之前,也可以向其中添加媒体轨道。因此,合理的做法是在尽可能早的阶段完成这些设置,而不是等到连接建立完成之后再进行。添加远端轨道
为了接收由另一端对等端添加的远端媒体轨道,需要在本地的
RTCPeerConnection 上注册一个监听器,用于监听 track 事件。该事件触发时,会收到一个 RTCTrackEvent 对象,其中包含一个 MediaStream 数组,这些流的 MediaStream.id 与远端对应的本地媒体流的 ID 保持一致。在本示例中,每个轨道只关联到一个媒体流。需要注意的是,虽然在点对点连接的两端,MediaStream 的 ID 是一致的,但 MediaStreamTrack 的 ID 通常并不一致。
const remoteVideo = document.querySelector('#remoteVideo'); peerConnection.addEventListener('track', async (event) => { const [remoteStream] = event.streams; remoteVideo.srcObject = remoteStream; });
数据通道
WebRTC 标准同样提供了一套 API,用于在
RTCPeerConnection 上发送任意类型的数据。这一功能通过在 RTCPeerConnection 对象上调用 createDataChannel() 方法来实现,该方法会返回一个 RTCDataChannel 对象。const peerConnection = new RTCPeerConnection(configuration); const dataChannel = peerConnection.createDataChannel();
远端对等端可以通过在其
RTCPeerConnection 对象上监听 datachannel 事件来接收数据通道。接收到的事件类型为 RTCDataChannelEvent,其中包含一个 channel 属性,表示在两个对等端之间建立的 RTCDataChannel。const peerConnection = new RTCPeerConnection(configuration); peerConnection.addEventListener('datachannel', event => { const dataChannel = event.channel; });
打开与关闭事件
在数据通道可以用于发送数据之前,客户端需要等待通道进入已打开状态。这可以通过监听 open 事件来实现。同样地,当任意一方关闭数据通道时,会触发 close 事件。
const messageBox = document.querySelector('#messageBox'); const sendButton = document.querySelector('#sendButton'); const peerConnection = new RTCPeerConnection(configuration); const dataChannel = peerConnection.createDataChannel(); // 当通道打开时启用输入框和按钮 dataChannel.addEventListener('open', event => { messageBox.disabled = false; messageBox.focus(); sendButton.disabled = false; }); // 当通道关闭时禁用输入 dataChannel.addEventListener('close', event => { messageBox.disabled = false; sendButton.disabled = false; });
消息传输
通过调用
RTCDataChannel 的 send() 方法即可发送消息。该方法支持的数据类型包括:字符串(string)、Blob、ArrayBuffer 以及 ArrayBufferView。const messageBox = document.querySelector('#messageBox'); const sendButton = document.querySelector('#sendButton'); // 点击按钮时发送一条简单的文本消息 sendButton.addEventListener('click', event => { const message = messageBox.textContent; dataChannel.send(message); });
远端对等端可以通过监听 message 事件来接收通过
RTCDataChannel 发送的数据。const incomingMessages = document.querySelector('#incomingMessages'); const peerConnection = new RTCPeerConnection(configuration); const dataChannel = peerConnection.createDataChannel(); // 将接收到的消息追加到消息显示区域 dataChannel.addEventListener('message', event => { const message = event.data; incomingMessages.textContent += message + '\n'; });
TURN 服务器
在大多数 WebRTC 应用中,由于客户端之间往往无法建立直接的套接字连接(除非它们位于同一局域网内),因此需要一个服务器来在对等端之间中继网络流量。解决这一问题的常见方式是使用 TURN 服务器。
TURN 是 Traversal Using Relays around NAT 的缩写,是一种用于中继网络流量的协议。
目前,在线上已经有多种 TURN 服务器可供选择,既可以作为自托管应用部署(例如开源的 COTURN 项目),也可以使用云服务提供的 TURN 服务。
当你拥有一个可用的 TURN 服务器后,只需要在客户端应用中提供正确的
RTCConfiguration 配置即可使用它。下面的代码示例展示了一个 RTCPeerConnection 的配置示例,其中 TURN 服务器的主机名为 my-turn-server.mycompany.com,运行在端口 19403 上。该配置对象还支持 username 和 credential 属性,用于对服务器访问进行身份验证。在连接 TURN 服务器时,这些参数是必需的。const iceConfiguration = { iceServers: [ { urls: 'turn:my-turn-server.mycompany.com:19403', username: 'optional-username', credential: 'auth-token' } ] } const peerConnection = new RTCPeerConnection(iceConfiguration);
