5、RTCPeerConnection基础:创建RTCPeerConnection对象、配置STUN/TURN服务器、ICE框架简介、添加本地流到连接
好,咱们今天来聊聊 RTCPeerConnection。这是 WebRTC 的核心中的核心。说白了,没有它,音视频数据就只能在本地打转,根本传不到对端去。
我个人习惯把 RTCPeerConnection 想象成一条「虚拟网线」。它负责把两个浏览器(或者 App)连接起来,让音视频数据能跑通。但这条网线不是插上就能用的,你得先把它「造」出来,再告诉它怎么找路,最后把本地流塞进去。
嗯,咱们一步步来。
5.1 创建 RTCPeerConnection 对象
创建对象本身很简单,一行代码的事。但这里有个坑——构造函数的参数。如果你什么都不传,浏览器会使用默认配置。默认配置在局域网环境下可能没问题,但一旦上了公网,基本就废了。
我曾经在一个 demo 项目里偷懒没传配置,结果内网测试一切正常,一部署到公网就全黑了。排查了半天,才发现是 ICE 服务器没配。
核心代码:
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
const pc = new RTCPeerConnection(config);
这里要注意,RTCPeerConnection 是浏览器原生提供的 API。你不需要引入任何第三方库。但不同浏览器对它的支持程度略有差异,我建议你始终在 Chrome 或 Firefox 上做开发调试。
5.2 配置 STUN/TURN 服务器
为什么需要 STUN 和 TURN?你想想看,两个设备在不同的内网里,它们怎么知道对方的公网地址?这就是 STUN 的活。
STUN 服务器的作用很简单:告诉你的设备「你的公网 IP 和端口是什么」。然后你把这条信息通过信令通道发给对方,对方就能尝试直连你。
但现实往往没那么美好。很多企业网络、校园网都有严格的 NAT 和防火墙。STUN 搞不定的时候,就需要 TURN 上场了。
TURN 服务器说白了就是一个中继。它把你的音视频数据先发到 TURN 服务器,再由服务器转发给对方。代价就是延迟增加、带宽消耗在服务器上。
我的建议:开发阶段用 Google 的公共 STUN 服务器就够了。生产环境一定要自建 TURN 服务器,或者购买商业服务。公共 STUN 只适合测试,不稳定且没有 SLA。
配置示例:
const config = {
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
},
{
urls: 'turn:your-turn-server.com:3478',
username: 'your-username',
credential: 'your-credential'
}
]
};
注意:TURN 服务器的 credential 不要硬编码在前端代码里。生产环境应该通过接口动态获取,或者使用临时令牌机制。我曾经见过有人把 TURN 密码写在 GitHub 上公开的 demo 里,结果服务器被人拿来当免费代理用,流量费一天跑了上千块。
5.3 ICE 框架简介
ICE 全称 Interactive Connectivity Establishment。名字很唬人,其实逻辑很简单:尝试所有可能的路径,找到最快能通的那条。
ICE 会收集三类候选地址(candidate):
- Host 候选:本机内网地址,比如 192.168.1.100
- Srflx 候选:通过 STUN 获取的公网地址
- Relay 候选:通过 TURN 获取的中继地址
ICE 会按照优先级从高到低尝试连接。Host 优先级最高,Relay 最低。如果直连成功,就不会走中继。只有直连全部失败,才会 fallback 到 TURN。
我画了一张图,帮你理解 ICE 的决策流程:
ICE 的状态变化可以通过 oniceconnectionstatechange 事件监听。我个人习惯在调试时把状态打印到控制台:
pc.oniceconnectionstatechange = () => {
console.log('ICE 状态:', pc.iceConnectionState);
// 可能的值: new, checking, connected, completed, failed, disconnected, closed
};
如果状态卡在 checking 很久然后变成 failed,十有八九是 STUN/TURN 配置有问题,或者网络环境太严格。
5.4 添加本地流到连接
有了连接对象,接下来就是把本地音视频流塞进去。这一步很简单,但有个顺序问题——一定要在发起 offer 之前添加流。
为什么?因为 offer 里会包含媒体信息(比如用了什么编码器、分辨率多少)。如果你先发 offer 再加流,对端收到的 offer 里就没有媒体信息,连接会失败。
标准流程:
// 1. 获取本地流
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// 2. 创建连接
const pc = new RTCPeerConnection(config);
// 3. 添加流到连接
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 4. 然后才创建 offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
这里有个细节:addTrack 的第二个参数要传 localStream 对象。这样对端收到流后,可以通过 streams 属性拿到完整的流对象,方便直接赋值给 <video> 标签。
避坑指南:我曾经在项目里用 addStream 旧 API(已废弃),结果在 Safari 上直接报错。现在统一用 addTrack 就对了。另外,如果你要动态切换摄像头,记得先 removeTrack 再 addTrack,否则浏览器会保留旧 track。
添加完本地流后,你可以把 localStream 直接赋值给一个 <video> 标签,实现「本地预览」效果:
const localVideo = document.getElementById('localVideo');
localVideo.srcObject = localStream;
嗯,到这里,RTCPeerConnection 的基础搭建就完成了。你有了连接对象,配好了 ICE 服务器,也把本地流挂上去了。下一步就是通过信令交换 SDP,让两端真正「握手」——不过那是下一节的内容了。