WebRTC 的实时音视频能力在浏览器中已经足够成熟,但把它真正落到 Vue 3 工程里,远不止在组件里 new 一个 RTCPeerConnection 那么简单。点对点通信涉及本地媒体采集、信令协商、ICE 候选交换、连接状态维护和资源释放等多个环节,每一步都要与 Vue 的响应式体系配合好,否则很容易出现视频黑屏、对方听不到声音、路由切换后摄像头仍被占用等问题。工程化处理的目标,就是把这些过程封装成清晰、可复用、可跟踪的模块。

一、先理解 WebRTC 点对点通信的关键对象
在浏览器中,WebRTC 的点对点音视频通信主要围绕三个对象展开:MediaStream、RTCPeerConnection 和信令通道。MediaStream 代表本地摄像头和麦克风采集到的原始音视频数据,通过 navigator.mediaDevices.getUserMedia 获取。它可以被直接绑定到 <video> 元素的 srcObject 属性上完成本地预览,也可以把其中的轨道添加到 RTCPeerConnection 中发送给远端。
RTCPeerConnection 是连接的核心,负责编码、传输、抖动缓冲和带宽估计。它并不关心信令如何传递,只暴露 createOffer、createAnswer、setLocalDescription、setRemoteDescription 等方法。真正需要开发者自己处理的是 SDP 描述和 ICE 候选的交换,这就是信令通道的作用。Vue 3 工程里通常用 WebSocket 或 Socket.IO 作为信令通道,后端只需要转发消息,不解析 SDP 内容。
理解了这三个对象的关系,才能避免把 WebRTC 的逻辑散落在组件里。散落的代码会让连接状态难以追踪,后续维护成本也会快速上升。接下来围绕这个基础,在 Vue 3 组合式 API 中做一层统一封装。
二、封装 useWebRTC:让媒体流和连接状态进入响应式体系
媒体流本质上是一个包含多个轨道的对象,不需要深度响应式代理。在 Vue 3 中可以使用 shallowRef 保存 localStream 和 remoteStream,避免底层原生对象被 reactive 代理后产生不可预期的行为。连接状态可以用 ref 存成字符串,方便在模板中直接展示呼叫中、已连接、已断开等状态。
下面是一个基础封装,包含本地流启动、连接创建和轨道绑定。
import { ref, shallowRef } from 'vue'
export function useWebRTC() {
const localStream = shallowRef(null)
const remoteStream = shallowRef(null)
const connectionState = ref('idle')
let peerConnection = null
async function startLocalStream() {
if (!navigator.mediaDevices) {
connectionState.value = 'unsupported'
return
}
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
localStream.value = stream
return stream
}
function createPeerConnection() {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
if (localStream.value) {
localStream.value.getTracks().forEach(function(track) {
pc.addTrack(track, localStream.value)
})
}
pc.onicecandidate = function(event) {
if (event.candidate) {
sendSignal({ type: 'candidate', candidate: event.candidate })
}
}
pc.ontrack = function(event) {
remoteStream.value = event.streams[0]
}
pc.onconnectionstatechange = function() {
connectionState.value = pc.connectionState
}
peerConnection = pc
return pc
}
return {
localStream,
remoteStream,
connectionState,
startLocalStream,
createPeerConnection
}
}
这个封装把底层对象保留在组合式函数内部,只暴露响应式引用和关键方法。调用方不需要直接操作 RTCPeerConnection,状态变化通过 connectionState 自动同步到组件。需要注意的是,ontrack 里的 event.streams[0] 是远端流对象,如果浏览器不支持该属性,可以回退为 new MediaStream([event.track])。
在实际呼叫流程中,发起方和接收方需要分别实现 offer 与 answer 的生成逻辑。下面的代码演示了主叫方和被叫方的核心路径。
async function call(targetId) {
if (!localStream.value) {
await startLocalStream()
}
const pc = createPeerConnection()
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
sendSignal({ type: 'offer', targetId, sdp: pc.localDescription })
}
async function accept(remoteSdp) {
if (!localStream.value) {
await startLocalStream()
}
const pc = createPeerConnection()
await pc.setRemoteDescription(remoteSdp)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
sendSignal({ type: 'answer', sdp: pc.localDescription })
}
async function addRemoteCandidate(candidate) {
if (peerConnection) {
await peerConnection.addIceCandidate(candidate)
}
}
这里有一个容易被忽略的时序问题:candidate 消息可能先于 answer 或 offer 到达。如果远端描述尚未设置,直接调用 addIceCandidate 会抛错。工程化处理时可以在收到 candidate 后先判断连接是否已经具备远端描述,否则将候选对象暂存到队列,等 setRemoteDescription 完成后再统一加入。
三、信令服务与房间状态管理
信令服务是点对点通信真正绕不开的一环。它不参与媒体传输,只负责交换 SDP 和 ICE 候选,并维护房间内用户列表。一个轻量的信令协议可以只包含几种消息类型:加入房间、用户列表、offer、answer、candidate 和离开房间。消息体尽量简单,避免把与业务无关的数据混入。
{
type: 'join',
roomId: 'room-001',
userId: 'user-a'
}
{
type: 'user-list',
users: ['user-a', 'user-b']
}
{
type: 'offer',
targetId: 'user-b',
sdp: { type: 'offer', sdp: '...' }
}
{
type: 'answer',
targetId: 'user-a',
sdp: { type: 'answer', sdp: '...' }
}
{
type: 'candidate',
targetId: 'user-b',
candidate: { candidate: '...', sdpMid: '0', sdpMLineIndex: 0 }
}
在 Vue 3 中可以把 WebSocket 连接封装成独立的 useSignaling 组合式函数,负责连接建立、消息分发和在线用户管理。这样 WebRTC 模块只处理媒体逻辑,信令模块只处理消息路由,两者通过回调或事件总线衔接。下面是一个简化实现。
import { ref } from 'vue'
export function useSignaling(url, handlers) {
const users = ref([])
const connected = ref(false)
let ws = null
function connect() {
ws = new WebSocket(url)
ws.onopen = function() {
connected.value = true
}
ws.onmessage = function(event) {
const message = JSON.parse(event.data)
if (message.type === 'user-list') {
users.value = message.users
}
if (message.type === 'offer') {
if (handlers.onOffer) {
handlers.onOffer(message)
}
}
if (message.type === 'answer') {
if (handlers.onAnswer) {
handlers.onAnswer(message)
}
}
if (message.type === 'candidate') {
if (handlers.onCandidate) {
handlers.onCandidate(message)
}
}
}
}
function send(message) {
if (ws) {
ws.send(JSON.stringify(message))
}
}
function close() {
if (ws) {
ws.close()
ws = null
}
}
return { users, connected, connect, send, close }
}
四、资源释放、设备切换与调试要点
单页应用中最常见的问题是路由切换后媒体设备仍然被占用。Vue 3 的 onBeforeUnmount 是处理这类清理逻辑的最佳位置。关闭连接时,应先移除事件监听、关闭 RTCPeerConnection,然后停止本地媒体轨道。如果只关闭连接而不停止轨道,摄像头指示灯不会熄灭。
import { onBeforeUnmount } from 'vue'
function closeAll() {
if (peerConnection) {
peerConnection.onicecandidate = null
peerConnection.ontrack = null
peerConnection.onconnectionstatechange = null
peerConnection.close()
peerConnection = null
}
if (localStream.value) {
localStream.value.getTracks().forEach(function(track) {
track.stop()
})
localStream.value = null
}
remoteStream.value = null
connectionState.value = 'closed'
}
onBeforeUnmount(function() {
closeAll()
})
设备切换也是视频通话中的常见需求。用户从后置摄像头切到前置摄像头,或从内置麦克风切到外接设备时,不需要重新建立连接,只需要替换对应发送器上的轨道。RTCRtpSender.replaceTrack 可以在不影响会话的情况下完成替换。下面代码演示了视频轨道切换。
async function switchCamera(deviceId) {
const newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId } },
audio: false
})
const videoTrack = newStream.getVideoTracks()[0]
const sender = peerConnection.getSenders().find(function(s) {
if (s.track) {
return s.track.kind === 'video'
}
return false
})
if (sender) {
await sender.replaceTrack(videoTrack)
}
const oldStream = localStream.value
if (oldStream) {
const oldTrack = oldStream.getVideoTracks()[0]
if (oldTrack) {
oldTrack.stop()
}
}
localStream.value = newStream
}
这段代码没有重建连接,所以远端几乎无感知,只会在切换瞬间出现短暂黑屏。实际项目中可以把 deviceId 来源接到 enumerateDevices 返回的设备列表,让用户在下拉框中选择。需要注意,切换成功后要停止旧轨道,否则可能出现两个摄像头同时占用的异常。
调试 WebRTC 时,Chrome 提供了 chrome://webrtc-internals 页面,可以查看 ICE 候选收集过程、连接状态变化、发送和接收码率、丢包率等详细数据。遇到连接失败时,优先区分是信令问题还是媒体传输问题:如果 connectionState 能到 connected 但看不到画面,通常与轨道绑定有关;如果一直停留在 connecting,则要检查 STUN 或 TURN 配置,确认双方是否处于对称 NAT 环境下。最后不要忘记,浏览器要求页面在 HTTPS 或 localhost 下才能使用摄像头和麦克风权限。