背景
我正在实现一个 FC 游戏网站, PC 用户仅需要配置键盘便能实现小伙伴们一起玩, 但是手机用户就比较麻烦了
传统的网页游戏都是通过 HTTP/WS 的方式实现联机, 对于服务器的负担还是比较重的. 实际上需要一起玩的小伙伴一般都在一块, 也没必要使用远端的服务器转发.
任意一个小伙伴的设备起一个服务也是一个好办法, 但是暂时还没考虑做 APP, 想要用户打开就能玩耍, 所以我坚持仅使用浏览器的功能
有小伙伴喜欢用手柄操作, 我考虑使用蓝牙联机, 但是 Web Bluetooth API 主要用于浏览器与蓝牙设备之间的通信(如智能手表、蓝牙耳机等), 而非直接实现浏览器之间的通信
最后我了解到了 WebRTC, 那就是我要的滑板鞋
什么是 WebRTC
?
WebRTC (Web Real-Time Communication), 网页及时交流
WebRTC 是一项开源技术, 旨在通过网页或移动应用程序实现点对点(P2P)的实时音视频、数据传输。WebRTC 允许用户无需通过中介服务器, 直接在浏览器之间进行音视频通信、文件共享、屏幕共享和实时数据传输, 广泛用于视频通话、在线会议、直播等场景。
简而言之, 这是一项网页之间直接通信的技术
WebRTC 的核心功能
WebRTC 提供了以下核心功能:
音频、视频通信:
WebRTC 能够通过点对点连接传输高质量的音频和视频数据, 支持实时视频通话和音频通话。它支持多种音视频编码器, 如 Opus 和 VP8、VP9 等。
数据传输:
除了音视频, WebRTC 还支持任意数据的传输。通过 RTCDataChannel, 可以进行低延迟的任意格式的数据传输, 如文件传输、聊天信息等。
安全性:
WebRTC 使用强大的加密技术, 所有数据传输都通过 SRTP(安全实时传输协议)和 DTLS(数据报传输层安全协议)加密, 确保通信的安全性。
如何使用 WebRTC
?
浏览器主要提供了 3 个 API
getUserMedia
这个 API 允许从用户的摄像头和麦克风中获取音视频流, 并将其捕获在 MediaStream 对象中。该对象可以通过 WebRTC 传输到远程浏览器, 也可以直接在本地页面播放。
navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => {
// 使用本地视频播放流
document.getElementById("localVideo").srcObject = stream;
})
.catch((error) => {
console.error("Error accessing media devices.", error);
});
RTCPeerConnection
RTCPeerConnection 是 WebRTC 的核心, 用于在两端建立音视频通信和数据通道。它支持网络协商(包括 SDP 会话描述协议)和处理网络中的 NAT(网络地址转换)穿透, 使得两个浏览器即使在不同网络下也可以建立直接连接。
const peerConnection = new RTCPeerConnection();
// 添加本地流
stream.getTracks().forEach((track) => peerConnection.addTrack(track, stream));
// 监听远端流
peerConnection.ontrack = (event) => {
const remoteStream = event.streams[0];
document.getElementById("remoteVideo").srcObject = remoteStream;
};
RTCDataChannel
RTCDataChannel 允许两个浏览器之间的任意数据传输, 适合传输文本、文件、游戏状态、实时聊天消息等内容, 支持低延迟和高性能。
const dataChannel = peerConnection.createDataChannel("chat");
dataChannel.onmessage = (event) => {
console.log("Received message:", event.data);
};
dataChannel.send("Hello!");
WebRTC 连接建立流程
-
创建 RTCPeerConnection
两个端点(浏览器)各自创建 RTCPeerConnection 实例, 用于管理 P2P 连接。
-
信令交换(SDP)
WebRTC 本身不定义信令机制, 需要借助第三方信令服务器(如 WebSocket、HTTP)来交换 SDP(Session Description Protocol)。SDP 描述了端点的音视频格式、网络信息等, 确保两个端点能够互相理解。
一方创建 offer, 发送给另一方, 另一方回复 answer。
// 创建 offer 并发送给远端 peerConnection.createOffer().then((offer) => { return peerConnection.setLocalDescription(offer); }); // 接收 answer 并设置为远端描述 peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
-
ICE(Interactive Connectivity Establishment)候选项交换
使用 ICE 来发现并交换每个浏览器的候选网络路径(如本地 IP、公共 IP 等), 以帮助浏览器之间建立 P2P 连接。ICE 通过 STUN/TURN 服务器帮助穿透 NAT 和防火墙。
-
建立 P2P 连接并传输数据
一旦 SDP 和 ICE 协商完成, 浏览器间建立 P2P 连接, 音视频和数据可以开始实时传输。
简单的案例
这里只调用基本的 API, 不做过多的介绍
创建 2 个 html 文件, 1.html
和2.html
, 用浏览器打开, 咱们直接控制台撸代码体验流程
创建 RTCPeerConnection
// 1.html
const p1 = new RTCPeerConnection();
// 2.html
const p2 = new RTCPeerConnection();
信令交换 SDP(Session Description Protocol)
这个过程通常是通过 WS 服务转发, 咱们这里主要体验流程, 所以手动操作
创建 offer 并设置为本地描述
// 1.html
p1.createOffer().then((offer) => {
// 设置为本地描述, 手动复制offer对象
p1.setLocalDescription(offer);
});
接收 offer 并设置为远端描述
// 2.html
// 将刚刚的offer设置为远端描述
p2.setRemoteDescription(offer);
创建 answer 并设置为本地描述
// 2.html
// 创建应答answer, 将answer设置为本地描述, 复制answer
p2.createAnswer().then((answer) => {
p2.setLocalDescription(answer);
});
接收 answer 并设置为远端描述
// 1.html
// 将刚刚的answer设置为远端描述
p1.setRemoteDescription(answer);
ICE(Interactive Connectivity Establishment)候选项交换
监听 icecandidate 事件
监听 icecandidate 事件, 获取 candidate
// 1.html
p1.onicecandidate = (event) => {
if (event.candidate) {
// 复制candidate
}
};
添加到对端
// 2.html
p2.addIceCandidate(candidate);
同理
// 2.html
p2.onicecandidate = (event) => {
if (event.candidate) {
// 复制candidate
}
};
// 1.html
p1.addIceCandidate(event.candidate);
这样, 基本的连接流程就完成了
一个简易聊天室
这个流程手动操作起来也挺麻烦的, 这里简化一下操作
打开a.html
会生成带参数的链接打开b.html
, 复制b.html
生成的信息填入a.html
, 这就是交换 SDP 和 ice 的过程
相关代码
a.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div class="config">
<a id="open" href="" target="_blank">打开新页面</a>
<p>打开页面复制sdp相关信息填入</p>
<textarea id="sdp" style="width: 100%; height: 200px"></textarea>
<button id="add">add sdp</button>
</div>
<div class="chat" style="display: none">
<div class="chat-box"></div>
<textarea class="chat-input"></textarea>
<button id="send">send</button>
</div>
<script>
const p1 = new RTCPeerConnection();
function send(msg) {
dataChannel.send(msg);
const div = document.createElement("div");
div.innerText = "我: " + msg;
const chat = document.querySelector(".chat-box");
chat.appendChild(div);
}
const dataChannel = p1.createDataChannel("chatChannel");
p1.ondatachannel = (event) => {
console.log(event);
};
dataChannel.onopen = () => {
console.log("DataChannel 已打开,可以发送消息");
const chat = document.querySelector(".chat");
const config = document.querySelector(".config");
chat.style.display = "block";
config.style.display = "none";
const btn = document.querySelector("#send");
btn.addEventListener("click", () => {
const input = document.querySelector(".chat-input");
send(input.value);
input.value = "";
});
};
dataChannel.onmessage = (event) => {
console.log("收到消息:", event.data);
const div = document.createElement("div");
div.innerText = "对方: " + event.data;
const chat = document.querySelector(".chat-box");
chat.appendChild(div);
};
p1.createOffer().then((offer) => {
p1.setLocalDescription(offer);
p1.onicecandidate = (event) => {
if (event.candidate) {
console.log(offer);
console.log(event.candidate);
const url = `${location.origin}/b.html?offer=${encodeURIComponent(
JSON.stringify(offer)
)}&candidate=${encodeURIComponent(
JSON.stringify(event.candidate)
)}`;
const open = document.querySelector("#open");
open.href = url;
}
};
});
const add = document.querySelector("#add");
add.addEventListener("click", () => {
const { answer, candidate } = JSON.parse(
document.querySelector("#sdp").value
);
p1.setRemoteDescription(answer);
p1.addIceCandidate(candidate);
});
</script>
</body>
</html>
b.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div class="config">
<button id="copy" disabled title="复制SDP ice信息">复制信息</button>
</div>
<div class="chat" style="display: none">
<div class="chat-box"></div>
<textarea class="chat-input"></textarea>
<button id="send">send</button>
</div>
<script>
const query = new URLSearchParams(location.search);
const offer = JSON.parse(decodeURIComponent(query.get("offer")));
const candidate = JSON.parse(decodeURIComponent(query.get("candidate")));
const p2 = new RTCPeerConnection();
p2.setRemoteDescription(offer);
p2.addIceCandidate(candidate);
p2.createAnswer().then((answer) => {
p2.setLocalDescription(answer);
p2.onicecandidate = (event) => {
if (event.candidate) {
console.log("生成的 ICE 候选者:", event.candidate);
const json = JSON.stringify({ candidate: event.candidate, answer });
const copy = document.querySelector("#copy");
copy.addEventListener("click", () => {
const input = document.createElement("input");
document.body.appendChild(input);
input.value = json;
input.select();
document.execCommand("copy");
document.body.removeChild(input);
});
copy.disabled = false;
}
};
});
let receiveChannel;
p2.ondatachannel = (event) => {
receiveChannel = event.channel;
receiveChannel.onopen = () => {
const config = document.querySelector(".config");
config.style.display = "none";
const chat = document.querySelector(".chat");
chat.style.display = "block";
console.log("DataChannel 已打开,可以接收消息");
const send = document.querySelector("#send");
send.addEventListener("click", () => {
const input = document.querySelector(".chat-input");
receiveChannel.send(input.value);
const div = document.createElement("div");
div.textContent = "我: " + input.value;
document.querySelector(".chat-box").appendChild(div);
input.value = "";
});
};
receiveChannel.onmessage = (event) => {
const div = document.createElement("div");
div.textContent = "对方: " + event.data;
document.querySelector(".chat-box").appendChild(div);
};
};
</script>
</body>
</html>
简易流媒体通信
既然 RTCPeerConnection 是个对象, 咱们可以一个页面创建两个对象来体验功能, 这样 SDP 和 ice 交换就简单了, 机智如我啊
相关代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<canvas width="500" height="400" id="canvas"></canvas>
<video id="video" autoplay muted></video>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let x = 50; // 圆的初始 x 坐标
let y = 50; // 圆的初始 y 坐标
let radius = 30; // 圆的半径
let dx = 2; // 圆在 x 方向上的增量
let dy = 2; // 圆在 y 方向上的增量
// 定义动画的绘制函数
function draw() {
// 清空 canvas,防止绘制的图形叠加
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制圆
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = "blue";
ctx.fill();
ctx.closePath();
// 更新圆的坐标
x += dx;
y += dy;
// 碰撞检测,使圆在边缘反弹
if (x + radius > canvas.width || x - radius < 0) {
dx = -dx; // 水平方向反弹
}
if (y + radius > canvas.height || y - radius < 0) {
dy = -dy; // 垂直方向反弹
}
// 请求下一帧动画
requestAnimationFrame(draw);
}
// 启动动画
draw();
const p1 = new RTCPeerConnection();
const stream = canvas.captureStream(60);
stream.getTracks().forEach((track) => {
p1.addTrack(track, stream);
console.log(track, stream);
});
const p2 = new RTCPeerConnection();
p2.ontrack = (event) => {
console.log("event", event);
const video = document.querySelector("#video");
video.srcObject = event.streams[0];
video.muted = true;
video.autoplay = true;
};
let receiveChannel;
p2.ondatachannel = (event) => {
receiveChannel = event.channel;
receiveChannel.onopen = () => {
console.log("DataChannel 已打开,可以接收消息");
};
receiveChannel.onmessage = (event) => {
console.log("收到消息:", event.data);
receiveChannel.send("Hello from Browser B");
};
};
const channel = p1.createDataChannel("channel");
channel.onopen = () => {
console.log("DataChannel 已打开,可以发送消息");
channel.send("Hello from Browser A");
};
channel.onmessage = (event) => {
console.log("收到消息:", event.data);
};
p1.onicecandidate = (event) => {
if (event.candidate) {
p2.addIceCandidate(event.candidate);
}
};
p2.onicecandidate = (event) => {
if (event.candidate) {
p1.addIceCandidate(event.candidate);
}
};
p1.createOffer().then((offer) => {
p1.setLocalDescription(offer);
p2.setRemoteDescription(offer);
p2.createAnswer().then((answer) => {
p2.setLocalDescription(answer);
p1.setRemoteDescription(answer);
console.log(offer, answer);
});
});
</script>
</body>
</html>
参考文献
WebRTC API
实现 WebRTC 群聊会议室
WebRTC 浅谈(一)概述与架构