每天一个高级前端知识 - Day 13
今日主题:WebRTC 深度实战 - 1对1视频通话与白板协作
核心概念:WebRTC让浏览器拥有实时通信能力
WebRTC(Web Real-Time Communication)支持直接在浏览器之间传输音视频和数据,无需中间服务器转发。
┌─────────┐ 信令服务器 ┌─────────┐
│ Peer A │ ←─── WebSocket ───→ │ Peer B │
└─────────┘ (交换SDP/ICE) └─────────┘
↓ ↓
STUN服务器 STUN服务器
(获取公网IP) (获取公网IP)
↓ ↓
TURN中继 ←─── P2P失败时中继 ───→ TURN中继
(备选通道) (备选通道)
🚀 完整的WebRTC视频通话实现
// ============ 1. 信令服务器端 (Node.js + Socket.io) ============
// server.js
const express = require('express');
const http = require('http');
const socketIO = require('socket.io');
const app = express();
const server = http.createServer(app);
const io = socketIO(server, {
cors: { origin: "*" }
});
// 房间管理
const rooms = new Map(); // roomId -> { users: Set, offer: null, answers: [] }
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
// 创建或加入房间
socket.on('join-room', (roomId, userId) => {
socket.join(roomId);
if (!rooms.has(roomId)) {
rooms.set(roomId, { users: new Set(), offer: null, answers: [] });
}
const room = rooms.get(roomId);
room.users.add(userId);
// 通知房间内其他用户
socket.to(roomId).emit('user-joined', userId);
// 如果是第二个用户且已有offer,发送offer
if (room.offer && room.users.size > 1) {
socket.emit('receive-offer', room.offer);
}
socket.emit('room-joined', {
roomId,
participants: Array.from(room.users)
});
});
// 处理Offer(发起呼叫)
socket.on('send-offer', (roomId, offer) => {
const room = rooms.get(roomId);
if (room) {
room.offer = offer;
socket.to(roomId).emit('receive-offer', offer);
}
});
// 处理Answer(应答)
socket.on('send-answer', (roomId, answer) => {
const room = rooms.get(roomId);
if (room) {
room.answers.push(answer);
socket.to(roomId).emit('receive-answer', answer);
}
});
// 处理ICE Candidate
socket.on('send-ice', (roomId, iceCandidate) => {
socket.to(roomId).emit('receive-ice', iceCandidate);
});
// 处理挂断
socket.on('hangup', (roomId, userId) => {
const room = rooms.get(roomId);
if (room) {
room.users.delete(userId);
socket.to(roomId).emit('user-left', userId);
if (room.users.size === 0) {
rooms.delete(roomId);
}
}
socket.leave(roomId);
});
socket.on('disconnect', () => {
console.log('用户断开:', socket.id);
// 清理用户所在的所有房间
for (const [roomId, room] of rooms) {
if (room.users.has(socket.id)) {
room.users.delete(socket.id);
socket.to(roomId).emit('user-left', socket.id);
}
}
});
});
server.listen(3001, () => {
console.log('信令服务器运行在 http://localhost:3001');
});
// ============ 2. 前端视频通话客户端 ============
// webrtc-client.js
class WebRTCVideoCall {
constructor(config = {}) {
this.config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, // Google STUN
{ urls: 'stun:stun1.l.google.com:19302' },
{
urls: 'turn:turn.anyfirewall.com:443?transport=tcp',
username: 'webrtc',
credential: 'webrtc'
}
],
iceCandidatePoolSize: 10,
...config
};
this.peerConnection = null;
this.localStream = null;
this.remoteStream = null;
this.roomId = null;
this.userId = null;
this.socket = null;
this.dataChannel = null;
// 回调函数
this.onLocalStream = null;
this.onRemoteStream = null;
this.onParticipantsChange = null;
this.onDataChannelMessage = null;
this.onError = null;
}
// 初始化信令连接
async init(signalServerUrl, roomId, userId) {
this.roomId = roomId;
this.userId = userId;
// 连接信令服务器
this.socket = io(signalServerUrl, {
transports: ['websocket'],
upgrade: false
});
this.setupSignalHandlers();
return new Promise((resolve) => {
this.socket.emit('join-room', roomId, userId);
this.socket.once('room-joined', (data) => {
resolve(data);
});
});
}
setupSignalHandlers() {
this.socket.on('user-joined', async (userId) => {
console.log('用户加入:', userId);
// 如果是第一个用户,创建Offer
if (!this.peerConnection) {
await this.createPeerConnection();
await this.createOffer();
}
this.onParticipantsChange?.(userId, 'joined');
});
this.socket.on('receive-offer', async (offer) => {
if (!this.peerConnection) {
await this.createPeerConnection();
}
await this.handleOffer(offer);
});
this.socket.on('receive-answer', async (answer) => {
await this.peerConnection.setRemoteDescription(answer);
});
this.socket.on('receive-ice', async (iceCandidate) => {
try {
await this.peerConnection.addIceCandidate(iceCandidate);
} catch (e) {
console.error('添加ICE Candidate失败:', e);
}
});
this.socket.on('user-left', (userId) => {
console.log('用户离开:', userId);
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
this.onParticipantsChange?.(userId, 'left');
});
}
// 获取用户媒体设备
async getUserMedia(constraints = { video: true, audio: true }) {
try {
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
// 枚举设备
const devices = await navigator.mediaDevices.enumerateDevices();
console.log('可用设备:', devices);
this.onLocalStream?.(this.localStream);
return this.localStream;
} catch (error) {
console.error('获取媒体失败:', error);
this.onError?.(error);
throw error;
}
}
// 创建PeerConnection
async createPeerConnection() {
this.peerConnection = new RTCPeerConnection(this.config);
// 添加本地流
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
this.peerConnection.addTrack(track, this.localStream);
});
}
// 监听ICE Candidate
this.peerConnection.onicecandidate = (event) => {
if (event.candidate) {
this.socket.emit('send-ice', this.roomId, event.candidate);
}
};
// 监听远程流
this.peerConnection.ontrack = (event) => {
if (!this.remoteStream) {
this.remoteStream = new MediaStream();
}
this.remoteStream.addTrack(event.track);
this.onRemoteStream?.(this.remoteStream);
};
// 监听连接状态
this.peerConnection.onconnectionstatechange = () => {
console.log('连接状态:', this.peerConnection.connectionState);
if (this.peerConnection.connectionState === 'failed') {
this.onError?.(new Error('连接失败'));
}
};
// 创建数据通道(用于白板协作)
this.dataChannel = this.peerConnection.createDataChannel('whiteboard', {
ordered: true, // 保证顺序
maxRetransmits: 3 // 最多重传3次
});
this.setupDataChannel();
return this.peerConnection;
}
setupDataChannel() {
this.dataChannel.onopen = () => {
console.log('数据通道已打开');
};
this.dataChannel.onclose = () => {
console.log('数据通道已关闭');
};
this.dataChannel.onmessage = (event) => {
const data = JSON.parse(event.data);
this.onDataChannelMessage?.(data);
};
this.dataChannel.onerror = (error) => {
console.error('数据通道错误:', error);
};
}
// 发送数据通道消息
sendDataChannelMessage(type, payload) {
if (this.dataChannel?.readyState === 'open') {
this.dataChannel.send(JSON.stringify({ type, payload, timestamp: Date.now() }));
} else {
console.warn('数据通道未就绪');
}
}
// 创建Offer
async createOffer() {
const offer = await this.peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
await this.peerConnection.setLocalDescription(offer);
this.socket.emit('send-offer', this.roomId, offer);
}
// 处理Offer
async handleOffer(offer) {
await this.peerConnection.setRemoteDescription(offer);
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
this.socket.emit('send-answer', this.roomId, answer);
}
// 挂断
hangup() {
if (this.peerConnection) {
this.peerConnection.close();
this.peerConnection = null;
}
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
this.remoteStream = null;
this.socket?.emit('hangup', this.roomId, this.userId);
}
// 切换摄像头
async switchCamera() {
// 获取当前视频轨道
const videoTrack = this.localStream?.getVideoTracks()[0];
if (!videoTrack) return;
// 获取新设备
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(device => device.kind === 'videoinput');
const currentDeviceId = videoTrack.getSettings().deviceId;
const currentIndex = cameras.findIndex(c => c.deviceId === currentDeviceId);
const nextCamera = cameras[(currentIndex + 1) % cameras.length];
if (nextCamera) {
const newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: nextCamera.deviceId },
audio: true
});
// 替换轨道
const newVideoTrack = newStream.getVideoTracks()[0];
const sender = this.peerConnection.getSenders().find(s => s.track?.kind === 'video');
await sender.replaceTrack(newVideoTrack);
// 更新本地流
const oldVideoTrack = this.localStream.getVideoTracks()[0];
this.localStream.removeTrack(oldVideoTrack);
this.localStream.addTrack(newVideoTrack);
oldVideoTrack.stop();
this.onLocalStream?.(this.localStream);
}
}
// 静音/取消静音
toggleMute() {
const audioTrack = this.localStream?.getAudioTracks()[0];
if (audioTrack) {
audioTrack.enabled = !audioTrack.enabled;
return audioTrack.enabled;
}
return false;
}
// 开启/关闭视频
toggleVideo() {
const videoTrack = this.localStream?.getVideoTracks()[0];
if (videoTrack) {
videoTrack.enabled = !videoTrack.enabled;
return videoTrack.enabled;
}
return false;
}
// 录制屏幕
async startScreenShare() {
try {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
});
// 替换视频轨道
const screenTrack = screenStream.getVideoTracks()[0];
const sender = this.peerConnection.getSenders().find(s => s.track?.kind === 'video');
await sender.replaceTrack(screenTrack);
// 监听屏幕共享结束
screenTrack.onended = () => {
this.stopScreenShare();
};
return true;
} catch (error) {
console.error('屏幕共享失败:', error);
return false;
}
}
async stopScreenShare() {
// 切换回摄像头
await this.switchCamera();
}
}
🎨 白板协作实现
// whiteboard.js
class CollaborativeWhiteboard {
constructor(canvas, webrtcClient) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.webrtcClient = webrtcClient;
this.isDrawing = false;
this.lastX = 0;
this.lastY = 0;
this.color = '#000000';
this.lineWidth = 2;
this.tool = 'pen'; // pen, eraser, rect, circle, line
this.setupCanvasEvents();
this.setupDataChannel();
this.initCanvas();
}
setupCanvasEvents() {
// 绘图事件
this.canvas.addEventListener('mousedown', this.startDrawing.bind(this));
this.canvas.addEventListener('mousemove', this.draw.bind(this));
this.canvas.addEventListener('mouseup', this.stopDrawing.bind(this));
this.canvas.addEventListener('mouseleave', this.stopDrawing.bind(this));
// 触摸事件(移动端)
this.canvas.addEventListener('touchstart', this.startDrawingTouch.bind(this));
this.canvas.addEventListener('touchmove', this.drawTouch.bind(this));
this.canvas.addEventListener('touchend', this.stopDrawing.bind(this));
// 调整画布大小
window.addEventListener('resize', this.resizeCanvas.bind(this));
this.resizeCanvas();
}
setupDataChannel() {
this.webrtcClient.onDataChannelMessage = (data) => {
if (data.type === 'draw') {
this.drawRemote(data.payload);
} else if (data.type === 'clear') {
this.clearCanvas();
} else if (data.type === 'undo') {
this.undo();
}
};
}
initCanvas() {
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// 保存历史记录(用于撤销)
this.history = [];
this.saveState();
}
resizeCanvas() {
const rect = this.canvas.parentElement.getBoundingClientRect();
const oldDrawing = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
this.canvas.width = rect.width;
this.canvas.height = rect.height;
this.ctx.putImageData(oldDrawing, 0, 0);
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
}
startDrawing(e) {
this.isDrawing = true;
const pos = this.getCanvasCoordinates(e);
this.lastX = pos.x;
this.lastY = pos.y;
if (this.tool === 'pen' || this.tool === 'eraser') {
this.ctx.beginPath();
this.ctx.moveTo(this.lastX, this.lastY);
// 发送绘图事件
this.sendDrawEvent('begin', this.lastX, this.lastY, this.lastX, this.lastY);
}
}
draw(e) {
if (!this.isDrawing) return;
const pos = this.getCanvasCoordinates(e);
const currentX = pos.x;
const currentY = pos.y;
if (this.tool === 'pen') {
this.ctx.globalCompositeOperation = 'source-over';
this.ctx.strokeStyle = this.color;
this.ctx.lineWidth = this.lineWidth;
this.ctx.lineTo(currentX, currentY);
this.ctx.stroke();
this.ctx.beginPath();
this.ctx.moveTo(currentX, currentY);
} else if (this.tool === 'eraser') {
this.ctx.globalCompositeOperation = 'destination-out';
this.ctx.strokeStyle = 'rgba(0,0,0,1)';
this.ctx.lineWidth = this.lineWidth * 2;
this.ctx.lineTo(currentX, currentY);
this.ctx.stroke();
this.ctx.beginPath();
this.ctx.moveTo(currentX, currentY);
}
// 发送绘图数据
this.sendDrawEvent('draw', this.lastX, this.lastY, currentX, currentY);
this.lastX = currentX;
this.lastY = currentY;
}
drawRemote(payload) {
if (payload.action === 'begin') {
this.ctx.beginPath();
this.ctx.moveTo(payload.x, payload.y);
} else if (payload.action === 'draw') {
this.ctx.globalCompositeOperation =
payload.tool === 'eraser' ? 'destination-out' : 'source-over';
this.ctx.strokeStyle = payload.color;
this.ctx.lineWidth = payload.lineWidth;
this.ctx.lineTo(payload.x2, payload.y2);
this.ctx.stroke();
this.ctx.beginPath();
this.ctx.moveTo(payload.x2, payload.y2);
}
}
sendDrawEvent(action, x, y, x2, y2) {
this.webrtcClient.sendDataChannelMessage('draw', {
action,
tool: this.tool,
color: this.color,
lineWidth: this.lineWidth,
x, y, x2, y2
});
}
stopDrawing() {
this.isDrawing = false;
this.ctx.beginPath();
this.saveState();
}
startDrawingTouch(e) {
e.preventDefault();
const touch = e.touches[0];
this.startDrawing(touch);
}
drawTouch(e) {
e.preventDefault();
const touch = e.touches[0];
this.draw(touch);
}
clearCanvas() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.webrtcClient.sendDataChannelMessage('clear', {});
this.saveState();
}
saveState() {
this.history.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
if (this.history.length > 50) {
this.history.shift();
}
}
undo() {
if (this.history.length > 1) {
this.history.pop();
const lastState = this.history[this.history.length - 1];
this.ctx.putImageData(lastState, 0, 0);
this.webrtcClient.sendDataChannelMessage('undo', {});
}
}
getCanvasCoordinates(e) {
const rect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / rect.width;
const scaleY = this.canvas.height / rect.height;
let clientX, clientY;
if (e.touches) {
clientX = e.touches[0].clientX;
clientY = e.touches[0].clientY;
} else {
clientX = e.clientX;
clientY = e.clientY;
}
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY
};
}
setTool(tool) {
this.tool = tool;
}
setColor(color) {
this.color = color;
}
setLineWidth(width) {
this.lineWidth = width;
}
downloadImage() {
const link = document.createElement('a');
link.download = 'whiteboard.png';
link.href = this.canvas.toDataURL();
link.click();
}
}
🎯 今日挑战
实现增强版WebRTC应用,要求:
- 支持多人视频会议(>3人)
- 实现动态带宽自适应(根据网络质量调整视频码率)
- 实现美颜滤镜(使用Canvas API)
- 实现虚拟背景(使用MediaPipe)
- 录制通话并保存为WebM文件
- 实时字幕(Web Speech API)
// 带宽自适应
class AdaptiveBitrateController {
constructor(peerConnection) {
this.pc = peerConnection;
this.lastBytesReceived = 0;
this.lastTimestamp = 0;
}
async monitorBandwidth() {
const stats = await this.pc.getStats();
let bytesReceived = 0;
let timestamp = 0;
stats.forEach(stat => {
if (stat.type === 'inbound-rtp' && stat.kind === 'video') {
bytesReceived = stat.bytesReceived;
timestamp = stat.timestamp;
}
});
if (this.lastBytesReceived > 0) {
const bitrate = (bytesReceived - this.lastBytesReceived) * 8 /
(timestamp - this.lastTimestamp);
// 根据带宽调整视频码率
if (bitrate < 500000) { // 低于500kbps
this.setVideoBitrate(300000); // 降低到300kbps
} else if (bitrate > 2000000) { // 高于2Mbps
this.setVideoBitrate(1500000); // 提高到1.5Mbps
}
}
this.lastBytesReceived = bytesReceived;
this.lastTimestamp = timestamp;
}
setVideoBitrate(bitrate) {
const sender = this.pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
const parameters = sender.getParameters();
if (!parameters.encodings) {
parameters.encodings = [{}];
}
parameters.encodings[0].maxBitrate = bitrate;
sender.setParameters(parameters);
}
}
}
📊 WebRTC性能优化
| 优化项 | 方法 | 效果 |
|---|---|---|
| 分辨率适配 | 动态调整视频分辨率 | 减少50%带宽 |
| Simulcast | 同时发送多路码流 | 提升弱网体验 |
| SVC | 可伸缩视频编码 | 降低30%延迟 |
| ICE重启 | 网络切换时重连 | 提升连接稳定性 |
明日预告:前端测试策略 - 单元测试、组件测试、E2E测试的完整实践
💡 WebRTC核心:P2P不是终点,混合架构才是大规模应用的答案!