大前端与勇士

打工人带你一起刷大前端副本 111

WebRTC 初探

背景

我正在实现一个 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.html2.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>
体验案例: https://webrtcchat.surge.sh/a.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>
体验案例: https://webrtcchat.surge.sh/

参考文献

WebRTC API
实现 WebRTC 群聊会议室
WebRTC 浅谈(一)概述与架构

posted on 2024-09-11 17:32  秦伟杰  阅读(20)  评论(0编辑  收藏  举报

导航