
打开微信视频号、抖音直播,或者和朋友用Discord语音游戏,背后运行的技术是什么?没有安装任何插件,你的浏览器凭什么能和千里之外的另一个浏览器直接通话,甚至不走服务器中转?
这就是WebRTC的魔力。
如果你在互联网大厂工作过,肯定听过这样的技术圆桌讨论:"我们考虑用WebRTC做实时互动功能,但是NAT穿透这个坎太难了,不如上TURN服务器。"这说明什么?说明WebRTC已经是前端工程师的必修课,但很多人用的时候还是迷迷糊糊,只会调API,不懂背后的原理。
本文就带你从源码级别理解WebRTC的核心机制,看看它如何通过SDP、ICE、DTLS这些听起来复杂的东西,在你的浏览器里实现真正的点对点通信。
在WebRTC出现之前,浏览器要做实时通信有多难?
想象一个场景:ByteDance的直播团队要在网页上做实时互动,他们怎么做?
旧时代方案:每一个音视频包都要经过后台服务器中转。用户A的摄像头数据 → 上传到服务器 → 服务器转发给用户B。这意味着什么?
所以那个时代的大型直播,基本上是专有协议+Flash插件,用户还得安装。
WebRTC来了以后呢?
点对点通信、端到端加密、无需插件。一句话:浏览器之间可以直接说话了。
WebRTC是一套浏览器API的集合,核心目标就三个字:分散流量。
你需要知道的最关键的几个概念:
任何WebRTC应用的起点都是一样的—获取用户的摄像头和麦克风权限。
代码很简单:
navigator.mediaDevices.getUserMedia({
video: true,
audio: true
})
.then(stream => {
// stream 就是你的摄像头+麦克风实时数据
document.getElementById('localVideo').srcObject = stream;
})
.catch(error => {
console.error('用户拒绝或无法访问设备:', error);
});
关键点:这不是简单的权限申请。浏览器这里做了什么?
所以如果你的实时通话功能在某些用户那里用不了,第一反应应该是检查权限。这在中国国内用户那里尤其常见,因为系统安全软件可能会拦截。
这是WebRTC的心脏。两个浏览器要通话,双方都需要创建一个RTCPeerConnection对象。
const peerConnection = new RTCPeerConnection({
iceServers: [
{ urls: ['stun:stun.l.google.com:19302'] },
{
urls: ['turn:your-turn-server.com:3478'],
username: 'user',
credential: 'pass'
}
]
});
// 把本地媒体流添加到连接中
stream.getTracks().forEach(track => {
peerConnection.addTrack(track, stream);
});
注意这个iceServers配置。这是什么?
STUN服务器和TURN服务器。继续往下看,这是整个WebRTC的难点。
两个浏览器要通话,首先要"商量"彼此的情况:
这个商量过程用的是SDP(Session Description Protocol)协议。WebRTC本身不规定怎么交换SDP—你可以用WebSocket、HTTP、甚至QQ私聊转发。
流程长这样:
主叫方 被叫方
| |
|---- 创建 Offer SDP --------→ |
| 设置Remote SDP
| 创建 Answer SDP
| ← ------ Answer SDP ------ |
设置Remote SDP |
代码实现:
// ===== 主叫方(发起通话的人)=====
peerConnection.createOffer()
.then(offer => {
// 1. 设置本地描述
return peerConnection.setLocalDescription(offer);
})
.then(() => {
// 2. 通过信令服务器发送给对方
socket.emit('offer', peerConnection.localDescription);
});
// ===== 被叫方(接听通话的人)=====
socket.on('offer', offer => {
peerConnection.setRemoteDescription(new RTCSessionDescription(offer))
.then(() => {
// 3. 创建应答
return peerConnection.createAnswer();
})
.then(answer => {
// 4. 设置本地描述
return peerConnection.setLocalDescription(answer);
})
.then(() => {
// 5. 发送给主叫方
socket.emit('answer', peerConnection.localDescription);
});
});
// ===== 主叫方接收应答 =====
socket.on('answer', answer => {
peerConnection.setRemoteDescription(
new RTCSessionDescription(answer)
);
});
常见坑:
❌ 错误做法:createOffer()还没完成就急着发送
✅ 正确做法:等到setLocalDescription()成功再发
这也是为什么很多公司做WebRTC功能时容易出"连接超时"的问题。
现在是2026年,全球有多少设备在NAT(网络地址转换)后面?你知道吗—几乎所有个人用户都在NAT后面。
你家的WiFi路由器背后,你的手机蜂窝网络背后,公司的防火墙后面。所有这些都是NAT。
问题:浏览器A在NAT后面,地址是192.168.1.100。浏览器B在另一个NAT后面,地址是192.168.1.50。两个私有地址怎么直连?
解决方案:WebRTC使用ICE(Interactive Connectivity Establishment)协议。原理很简单,但实施复杂:
具体代码:
// ===== 收集 ICE 候选地址 =====
peerConnection.onicecandidate = event => {
if (event.candidate) {
// 有新的候选地址,发送给对方
socket.emit('ice-candidate', {
candidate: event.candidate.candidate,
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid
});
} else {
console.log('ICE 候选地址收集完成');
}
};
// ===== 接收对方的 ICE 候选地址 =====
socket.on('ice-candidate', ({ candidate, sdpMLineIndex, sdpMid }) => {
if (candidate) {
peerConnection.addIceCandidate(
new RTCIceCandidate({
candidate,
sdpMLineIndex,
sdpMid
})
);
}
});
现在问一个问题:这些ICE候选地址是从哪来的?
答案是STUN和TURN服务器。
STUN(Session Traversal Utilities for NAT)是什么意思?就是"告诉我我在公网上的地址是什么"。
浏览器(192.168.1.100)
↓ 问一下我的公网地址
STUN服务器
↓ 我看到你来自 203.0.113.45:58392
浏览器 ← 收到,我知道了我的公网地址是 203.0.113.45
Google提供的免费STUN服务器就是这样工作的。成功率在60-70%,为什么不是100%?因为有些NAT类型特别"凶悍"(Symmetric NAT),它会随机分配端口,导致从STUN获取的地址在其他连接中就失效了。
TURN(Traversal Using Relays around NAT)就是"我帮你中转"的意思。
浏览器A ↓
TURN服务器 ← 浏览器B
浏览器A → (服务器中转所有数据)← 浏览器B
TURN服务器会消耗大量带宽。为什么还要用?因为某些情况下P2P就是连不上,没办法。阿里、腾讯这样的大厂都自建TURN集群,专门处理这种情况。
国内开发者的常见困境:Google的STUN服务器在中国经常被墙,所以你需要部署自己的STUN/TURN服务器。推荐用开源的Coturn。
让我们把上面的所有概念串起来,实现一个最小化但完整的视频通话应用。
┌─────────────────────────────────────────────────────────┐
│ WebRTC 视频通话流程 │
└─────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐
│ 浏览器 A │ │ 浏览器 B │
│ (主叫方) │ │ (被叫方) │
└──────────────┘ └──────────────┘
│ │
├─ getUserMedia() │
│ (获取摄像头/麦克风) │
│ │
├─ createOffer() │
├─ setLocalDescription(offer) │
│ │
│ offer (SDP) │
├──────────────────────────────────────────→├─ setRemoteDescription(offer)
│ │
│ ├─ getUserMedia()
│ ├─ createAnswer()
│ ├─ setLocalDescription(answer)
│ │
│ answer (SDP) │
│←──────────────────────────────────────────┤
│ │
├─ setRemoteDescription(answer) │
│ │
│ ICE candidates │
├──────────────────────────────────────────→├─ addIceCandidate()
│←──────────────────────────────────────────┤
│ │
│ P2P connection established │
│◄─────────────────────────────────────────→│
│ │
│ media stream flowing directly │
│◄─────────────────────────────────────────→│
│ │
class SimpleWebRTC {
constructor(signalingServer) {
this.signalingServer = signalingServer;
this.peerConnection = null;
this.localStream = null;
this.remoteStream = new MediaStream();
// 初始化信令连接
this.socket = io(signalingServer);
this.setupSocketListeners();
}
// 步骤1: 获取本地媒体
async getLocalMedia() {
try {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 1280 }, height: { ideal: 720 } },
audio: true
});
document.getElementById('localVideo').srcObject = this.localStream;
} catch (error) {
console.error('获取媒体失败:', error);
throw error;
}
}
// 步骤2: 初始化PeerConnection
initPeerConnection() {
this.peerConnection = new RTCPeerConnection({
iceServers: [
// 使用自己部署的 STUN/TURN 服务器,避免国内墙的问题
{ urls: ['stun:stun.example.com:3478'] },
{
urls: ['turn:turn.example.com:3478'],
username: 'user',
credential: 'pass'
}
]
});
// 添加本地音视频轨道
this.localStream.getTracks().forEach(track => {
this.peerConnection.addTrack(track, this.localStream);
});
// 监听远端媒体流
this.peerConnection.ontrack = event => {
console.log('收到远端媒体:', event.track.kind);
event.streams[0].getTracks().forEach(track => {
this.remoteStream.addTrack(track);
});
document.getElementById('remoteVideo').srcObject = this.remoteStream;
};
// 监听连接状态变化
this.peerConnection.onconnectionstatechange = () => {
console.log('连接状态:', this.peerConnection.connectionState);
};
// 监听 ICE 候选地址
this.peerConnection.onicecandidate = event => {
if (event.candidate) {
this.socket.emit('ice-candidate', event.candidate);
}
};
}
// 步骤3: 主叫方发起通话
async initiateCall() {
this.initPeerConnection();
try {
const offer = awaitthis.peerConnection.createOffer();
awaitthis.peerConnection.setLocalDescription(offer);
this.socket.emit('offer', offer);
} catch (error) {
console.error('创建 offer 失败:', error);
}
}
// 步骤4: 被叫方响应通话
async handleOffer(offer) {
this.initPeerConnection();
try {
awaitthis.peerConnection.setRemoteDescription(
new RTCSessionDescription(offer)
);
const answer = awaitthis.peerConnection.createAnswer();
awaitthis.peerConnection.setLocalDescription(answer);
this.socket.emit('answer', answer);
} catch (error) {
console.error('创建 answer 失败:', error);
}
}
// 步骤5: 处理信令消息
setupSocketListeners() {
this.socket.on('offer', offer => {
this.handleOffer(offer);
});
this.socket.on('answer', answer => {
this.peerConnection.setRemoteDescription(
new RTCSessionDescription(answer)
);
});
this.socket.on('ice-candidate', candidate => {
if (candidate) {
this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
}
});
}
// 关闭连接
closeConnection() {
this.localStream?.getTracks().forEach(track => track.stop());
this.peerConnection?.close();
}
}
// 使用示例
const rtc = new SimpleWebRTC('http://signaling-server.com');
// 主叫方:启动通话
asyncfunction startCall() {
await rtc.getLocalMedia();
await rtc.initiateCall();
}
// 或者被叫方会自动处理
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 = {};
io.on('connection', socket => {
console.log('用户连接:', socket.id);
socket.on('join-room', roomId => {
socket.join(roomId);
const roomClients = io.sockets.adapter.rooms.get(roomId);
const clientCount = roomClients?.size || 0;
// 通知房间内的其他用户有人加入
socket.to(roomId).emit('user-joined', {
userId: socket.id,
totalUsers: clientCount
});
});
// 转发 offer
socket.on('offer', offer => {
socket.broadcast.emit('offer', offer);
});
// 转发 answer
socket.on('answer', answer => {
socket.broadcast.emit('answer', answer);
});
// 转发 ICE 候选地址
socket.on('ice-candidate', candidate => {
socket.broadcast.emit('ice-candidate', candidate);
});
socket.on('disconnect', () => {
console.log('用户断开连接:', socket.id);
socket.broadcast.emit('user-left', socket.id);
});
});
server.listen(3000, () => {
console.log('信令服务器运行在 3000 端口');
});
说实话,WebRTC的最大成本不是在开发,而是在NAT穿透。
在我国,如果你做一个连接Symmetric NAT和严格防火墙的用户(比如校园网、公司网络),直连成功率会很低。数据显示:
所以你需要TURN服务器作为备选方案。一个TURN服务器每个月处理10Gbps流量的成本,在国内云厂商那里,粗略估计需要每月几万人民币。
行业做法:
WebRTC是点对点协议,天然支持N对N网状连接。但问题来了:如果有10个人在一个会议里,每个人都要和其他9个人建立连接,这就是90条P2P链路。
每一条链路都要:
一个普通笔记本电脑,同时承载5-6个P2P视频连接就开始掉帧了。
解决方案:使用SFU(Selective Forwarding Unit)或MCU(Multipoint Control Unit)。
传统 P2P(10 人会议) 用 SFU(10 人会议)
每人 9 条上下行链路 每人 1 条上行 + 1 条下行
总共 90 条链路 总共 20 条链路
开源方案有mediasoup和Janus,都很成熟。但这相当于又回到了"服务器中转"方案,只不过不再处理每一个数据包,而是做智能转发。
✅ 用纯WebRTC
// 这种场景直连成功率70%+,TURN备选方案
// 成本最低,体验最好
✅ 用SFU + WebRTC
// 用 mediasoup 或 Janus
// 前几个客户端P2P,超过阈值自动切换到SFU
✅ 用CDN分发 + WebRTC back channel
// 主播端用WebRTC到服务器
// 观众端用CDN/HLS下行
// 互动通道用WebRTC DataChannel(聊天、点赞)
大多数人只知道WebRTC用来传音视频。但其实它的数据通道才是黑科技。
DataChannel是一个低延迟、点对点的数据传输通道。和WebSocket不同,它不需要经过服务器。
// 创建数据通道
const dataChannel = peerConnection.createDataChannel('game-data', {
ordered: false, // 是否保证顺序
maxRetransmits: 3// 重试次数
});
dataChannel.onopen = () => {
console.log('数据通道已打开');
};
dataChannel.onmessage = event => {
console.log('收到数据:', event.data);
};
dataChannel.send(JSON.stringify({
type: 'player-move',
x: 100,
y: 200
}));
用WebRTC DataChannel做什么?
案例:某游戏公司做一款网页版"元宇宙"社交应用,用WebRTC DataChannel传输玩家的实时动作,延迟从WebSocket的100ms降到了15ms。
特性 | DataChannel | WebSocket |
|---|---|---|
延迟 | 10-50ms | 50-200ms |
需要服务器 | 否 | 是 |
连接建立 | 快 | 快 |
带宽消耗 | 低 | 中等 |
可靠性 | 可配置 | 100%可靠 |
浏览器支持 | 95%+ | 99%+ |
互联网时代,隐私是金。WebRTC在这方面做得很硬核。
所有WebRTC的媒体和数据都默认使用DTLS-SRTP加密。注意:"默认"这个词—你根本没办法关闭。
浏览器A 浏览器B
│ 明文:我的SDP │
├──────────────────────→ │
│ DTLS握手(建立加密通道) │
├──────────────────────→ │
│ 加密后的媒体数据 │
├════════════════════→ │
│ │
这意味着什么?
DTLS(Datagram Transport Layer Security)本质上是在UDP上应用TLS加密。为什么不用TCP?因为WebRTC需要低延迟,TCP会导致队头阻塞(Head-of-Line Blocking),丢包时整个连接延迟。
// 作为开发者,你不需要手动配置加密
// WebRTC 会自动处理所有加密细节
const peerConnection = new RTCPeerConnection();
// ✅ 一切通信都是加密的,无需额外代码
这里有个有趣的问题:如果通话都加密了,怎么防止中间人攻击?
答案是:WebRTC只保证"传输安全",不保证"身份认证"。你需要在应用层做认证。
// ❌ 不安全的做法
// 用户直接点击"呼叫"按钮
// ✅ 安全的做法
// 1. 用户登录(身份认证)
// 2. 应用层验证呼叫请求来自真实用户
// 3. 建立WebRTC连接时,再加上应用层令牌验证
const offerWithToken = {
offer: peerConnection.localDescription,
token: jwt_token // 应用层认证令牌
};
socket.emit('offer', offerWithToken);
作为国内做WebRTC开发的工程师,你需要关注这些问题:
Google的免费STUN服务器经常不可用。解决方案:
// 优先使用自建 STUN,备选方案
iceServers: [
{ urls: ['stun:stun.example.com:3478'] }, // 自建
{ urls: ['stun:stun.l.google.com:19302'] }, // Google(可能不可用)
{ urls: ['stun:stun1.l.google.com:19302'] }
]
WebRTC的信令通常需要跨域通信。确保你的信令服务器有正确的CORS配置:
const io = require('socket.io')(server, {
cors: {
origin: 'https://your-app.com',
credentials: true
}
});
iOS WebRTC支持有限制(特别是Safari),Android基本没问题。
// 兼容性检查
function checkWebRTCSupport() {
const rtcPeerConnection =
window.RTCPeerConnection ||
window.webkitRTCPeerConnection ||
window.mozRTCPeerConnection;
return !!rtcPeerConnection;
}
某些运营商会限制P2P连接。如果你发现某类用户连接失败率特别高,很可能是运营商问题:
// 添加超时机制,及时降级到 TURN
const iceGatheringTimer = setTimeout(() => {
if (peerConnection.iceConnectionState === 'checking') {
console.log('ICE 连接超时,强制使用 TURN');
// 降级策略
}
}, 10000);
WebRTC 走过了10多年的发展历程,从"浏览器黑科技"变成了"行业标准"。
它解决了什么?
它还有什么局限?
未来方向?
现在,如果你是一名前端工程师,WebRTC已经不是"选修课",而是必修课。
从小的一对一通话,到大的实时游戏、直播互动、协同编辑,WebRTC的应用场景正在爆炸式增长。ByteDance、Alibaba、Tencent这样的大厂都在投入WebRTC相关技术,中小团队也在快速跟进。
你准备好了吗?
感谢你读到这里。这篇文章从概念、原理、代码、安全、国情等多个角度剖析了WebRTC这个复杂但强大的技术。
我知道WebRTC的学习曲线很陡峭—信令协商、ICE候选地址、TURN服务器这些概念让很多开发者一度想放弃。但当你真正理解它的工作机制后,你会发现这个技术的设计思想精妙绝伦。
关键知识点回顾:
如果这篇文章对你有帮助,请:
在《前端达人》,我们不只讲API怎么用,而是深入源码、性能、原理、最佳实践。无论你是想快速学习还是想深度研究,这里都有你需要的内容。
下期我们继续聊WebRTC在实际项目中的性能优化和故障排查。敬请期待!
《前端达人》—与其他公众号不同的是,我们真正关心你的技术成长。
点击关注,让我们一起探索前端的无限可能 ↓