我使用 WebRTC 与你 - 使用 JavaScript 构建视频聊天

2025-06-09

我使用 WebRTC 与你 - 使用 JavaScript 构建视频聊天

在最近的一个大学项目中,我们团队的任务是为我们的 iOS 和 Web 应用提供视频通话功能。市面上有很多承诺提供视频通话功能的解决方案,但只有少数是免费的,而且大多数只适用于单一平台。由于我们必须同时为 iOSWeb 构建这个功能,我们决定使用普通的 WebRTC,因为“应该没那么难吧 ¯\_(ツ)_/¯”

总结

我记得自己浏览过博客文章和教程,试图找到所需的最低限度步骤,最终甚至读完了Signal iOS 代码库。以下是开始使用 WebRTC 所需了解的基本要点(或者至少搜索一下你的项目中哪些地方不起作用):

  • STUN 类似于traceroute:它收集您和 STUN 服务器之间的“跳数”;这些跳数被称为 ICE 候选
  • ICE 候选对象基本上是ip:port成对的;您可以使用这些候选对象“联系”您的应用程序
  • 你需要双工连接来在调用方之间交换数据。考虑使用 WebSocket 服务器,因为这是实现此目的最简单的方法
  • 当一方“发现”ICE 候选时,通过 WebSocket/双工通道将其发送给另一方
  • 获取设备的媒体曲目并将其添加到本地RTCPeerConnection
  • 在您的 上创建 WebRTC 报价RTCPeerConnection,并将其发送给对方
  • 接收并使用该优惠,然后回复您的答案

如果这篇文章没有帮助你解决问题,或者你对 WebRTC 感兴趣,请继续阅读。我们先来了解一下什么是 WebRTC,然后再搭建一个小型视频聊天系统。

什么是 WebRTC?

我仅借用官方网站的“关于”部分:

WebRTC 是一个免费开放的项目,它通过简单的 API 为浏览器和移动应用程序提供实时通信 (RTC) 功能。WebRTC 组件已针对此目的进行了优化
。—— webrtc.org

简而言之,WebRTC 允许您构建使用点对点连接实时交换数据的应用程序。数据可以是音频、视频或任何您想要的数据。例如,Signal 通话是通过纯 WebRTC 进行的,并且由于其点对点的特性,大多数情况下无需通过第三方发送通话数据,例如像Skype 现在所做的那样。

眩晕

为了在两个呼叫方之间建立点对点连接,他们需要知道如何相互连接。这就是 STUN 的用武之地。如上所述,它类似于traceroute

在 JavaScript 中创建 WebRTC 客户端对象时,需要提供iceServerUrls,它们本质上是 STUN 服务器的 URL。客户端会经过所有跳转,直到到达 STUN 服务器。以下序列图以简化的方式展示了它的工作原理:

STUN 跳数序列图

候选人离 Alice 越“远”(到达她所需的跳数越多),其网络成本就越高。localhost:12345比 更接近她public_ip:45678,因此localhost成本可能是 10,而public_ip可能是 100。WebRTC 尝试建立具有最低网络成本的连接,以确保高带宽。

优惠、答案和轨迹

如果您想与朋友进行 FaceTime 通话,他们可能会有兴趣知道您如何呼叫他们,即他们想看看您是否仅使用音频或视频,或者甚至您是否根本没有使用 FaceTime 而只是通过座机呼叫他们。

WebRTC Offer与此类似:您可以指定在即将到来的连接中发送的内容。因此,当您 时peer.createOffer(),它会检查哪些轨道(例如视频或音频)存在,并将其包含在 Offer 中。被叫方收到 Offer 后,它peer.createAnswer()会指定自己的功能,例如是否同时发送音频和视频。

信号

WebRTC 的一个重要部分是在建立点对点连接之前交换信息。双方需要交换一个请求和答案,并且需要知道对方的 ICE 候选,否则他们将不知道该将音频和视频流发送到哪里。

这就是信令的作用所在:你需要将上述信息发送给双方。你可以使用任何方式来实现这一点,但最简单的方法是使用双工连接,例如 WebSockets 提供的连接。使用 WebSocket,只要信令服务器有更新,你就会收到“通知”。

典型的 WebRTC 握手如下所示:

WebRTC握手

首先,Alice 发出信号,她想打电话给 Bob,于是双方发起 WebRTC“握手”。他们都获取各自的ICE 候选,并通过信令服务器将其发送给对方。在某个时刻,Alice 创建一个 offer 并将其发送给 Bob。谁先创建offer并不重要(Alice 还是 Bob),但对方必须创建offer的answer。由于 Alice 和 Bob 都知道如何联系对方以及需要发送哪些数据,因此点对点连接建立,他们可以开始对话。

构建它

现在我们知道了 WebRTC 的工作原理,接下来就“只需”构建它了。这篇文章将只关注如何使用 Web 客户端,如果评论区有人对 iOS 版本感兴趣,我会在另一篇文章中总结其中的陷阱。另外,我目前已经将 Web 客户端实现为一个 React hook useWebRTC,我可能会为此专门写一篇文章。

服务器将使用 TypeScript,而 Web 应用将使用纯 JavaScript,这样就无需单独的构建过程。两者都只使用纯 WebSocket 和 WebRTC,没有什么特别之处。您可以在GitHub上找到本文的源代码。

服务器

我们将使用expressexpress-ws一堆其他库,您可以在package.json中找到它们。

WebSocket 通道

许多 WebSocket 库允许在通道 (channel)中发送数据。本质上, 通道 (channel) 只是消息中的一个字段(例如 like { channel: "foo", data: ... }),允许服务器和应用程序区分消息所属的位置。

我们需要 5 个频道:

  • start_call:表示应该开始通话
  • webrtc_ice_candidate:交换 ICE 候选人
  • webrtc_offer:发送 WebRTC 请求
  • webrtc_answer:发送 WebRTC 答案
  • login:让服务器知道你是谁

浏览器端的 WebSocket 实现缺乏发送用户身份的功能,例如,Authorization无法在标头中添加包含令牌的信息。我们可以通过 WebSocket 的 URL 将令牌作为查询参数添加,但这意味着它会被记录到 Web 服务器上,并可能被缓存在浏览器中——我们不希望出现这种情况。

相反,我们将使用一个单独的login通道,只发送我们的名字。这可以是令牌或其他任何东西,但为了简单起见,我们假设我们的名字足够安全且唯一。

由于我们使用 TypeScript,我们可以轻松地为消息定义接口,因此我们可以安全地交换消息而不必担心拼写错误:

interface LoginWebSocketMessage {
  channel: "login";
  name: string;
}

interface StartCallWebSocketMessage {
  channel: "start_call";
  otherPerson: string;
}

interface WebRTCIceCandidateWebSocketMessage {
  channel: "webrtc_ice_candidate";
  candidate: RTCIceCandidate;
  otherPerson: string;
}

interface WebRTCOfferWebSocketMessage {
  channel: "webrtc_offer";
  offer: RTCSessionDescription;
  otherPerson: string;
}

interface WebRTCAnswerWebSocketMessage {
  channel: "webrtc_answer";
  answer: RTCSessionDescription;
  otherPerson: string;
}

// these 4 messages are related to the call itself, thus we can
// bundle them in this type union, maybe we need that later
type WebSocketCallMessage =
  StartCallWebSocketMessage
  | WebRTCIceCandidateWebSocketMessage
  | WebRTCOfferWebSocketMessage
  | WebRTCAnswerWebSocketMessage;

// our overall type union for websocket messages in our backend spans
// both login and call messages
type WebSocketMessage = LoginWebSocketMessage | WebSocketCallMessage;
Enter fullscreen mode Exit fullscreen mode

由于我们在这里使用了联合类型,稍后我们可以通过检查属性来使用 TypeScript 编译器识别收到的消息类型channel。如果是message.channel === "start_call",编译器就会推断该消息的类型一定是StartCallWebSocketMessage。简洁。

公开 WebSocket

我们将使用express-ws从我们的服务器公开 WebSocket,该服务器恰好是一个快速应用程序,通过以下方式提供服务http.createServer()

const app = express();
const server = createServer(app);

// serve our webapp from the public folder
app.use("/", express.static("public"));

const wsApp = expressWs(app, server).app;

// expose websocket under /ws
// handleSocketConnection is explained later
wsApp.ws("/ws", handleSocketConnection);

const port = process.env.PORT || 3000;
server.listen(port, () => {
  console.log(`server started on http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

我们的应用程序现在将在端口 3000(或我们通过提供的任何端口PORT)上运行,公开一个 WebSocket/ws并从public目录中为我们的 web 应用程序提供服务。

用户管理

由于视频通话通常需要 1 人以上参与,我们还需要跟踪当前连接的用户。为此,我们可以引入一个数组connectedUsers,每当有人连接到 WebSocket 时,我们都会更新该数组:

interface User {
  socket: WebSocket;
  name: string;
}

let connectedUsers: User[] = [];
Enter fullscreen mode Exit fullscreen mode

此外,为了方便起见,我们应该添加辅助函数来通过用户姓名或套接字来查找用户:

function findUserBySocket(socket: WebSocket): User | undefined {
  return connectedUsers.find((user) => user.socket === socket);
}

function findUserByName(name: string): User | undefined {
  return connectedUsers.find((user) => user.name === name);
}
Enter fullscreen mode Exit fullscreen mode

在本文中,我们假设没有恶意行为者。因此,每当一个套接字连接上时,都表示有人试图尽快给其他人打电话。我们的handleSocketConnection情况大致如下:

function handleSocketConnection(socket: WebSocket): void {
  socket.addEventListener("message", (event) => {
    const json = JSON.parse(event.data.toString());

    // handleMessage will be explained later
    handleMessage(socket, json);
  });

  socket.addEventListener("close", () => {
    // remove the user from our user list
    connectedUsers = connectedUsers.filter((user) => {
      if (user.socket === socket) {
        console.log(`${user.name} disconnected`);
        return false;
      }

      return true;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

WebSocket 消息可以是字符串或Buffers,所以我们需要先解析它们。如果是 s Buffer,调用toString()会将其转换为字符串。

转发消息

我们的信令服务器本质上是在调用双方之间转发消息,如上面的序列图所示。为此,我们可以创建另一个便捷函数forwardMessageToOtherPerson,它将传入的消息发送到otherPerson消息中指定的地址。为了方便调试,我们甚至可以将otherPerson字段替换为发送原始消息的发送者:

function forwardMessageToOtherPerson(sender: User, message: WebSocketCallMessage): void {
  const receiver = findUserByName(message.otherPerson);
  if (!receiver) {
    // in case this user doesn't exist, don't do anything
    return;
  }

  const json = JSON.stringify({
    ...message,
    otherPerson: sender.name,
  });

  receiver.socket.send(json);
}
Enter fullscreen mode Exit fullscreen mode

在我们的 中handleMessage,我们可以登录用户,并可能将他们的消息转发给其他人。请注意,所有与通话相关的消息都可以合并到default语句中,但为了让日志记录更有意义,我明确地将每个频道都放在那里:

function handleMessage(socket: WebSocket, message: WebSocketMessage): void {
  const sender = findUserBySocket(socket) || {
    name: "[unknown]",
    socket,
  };

  switch (message.channel) {
    case "login":
      console.log(`${message.name} joined`);
      connectedUsers.push({ socket, name: message.name });
      break;

    case "start_call":
      console.log(`${sender.name} started a call with ${message.otherPerson}`);
      forwardMessageToOtherPerson(sender, message);
      break;

    case "webrtc_ice_candidate":
      console.log(`received ice candidate from ${sender.name}`);
      forwardMessageToOtherPerson(sender, message);
      break;

    case "webrtc_offer":
      console.log(`received offer from ${sender.name}`);
      forwardMessageToOtherPerson(sender, message);
      break;

    case "webrtc_answer":
      console.log(`received answer from ${sender.name}`);
      forwardMessageToOtherPerson(sender, message);
      break;

    default:
      console.log("unknown message", message);
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

服务器部分就是这样。当有人连接到套接字时,他们可以登录,并且一旦他们启动 WebRTC 握手,消息就会转发给他们正在呼叫的人。

Web 应用程序

index.html该 Web 应用由和一个 JavaScript 文件组成web.js。如上所示,它们均由public应用目录提供。Web 应用最重要的部分是两个<video />标签,用于显示本地和远程视频流。为了获得一致的视频源,autoplay需要在视频上设置 ,否则它将卡在初始帧:

<!DOCTYPE html>
<html>
  <body>
    <button id="call-button">Call someone</button>

    <div id="video-container">
      <div id="videos">
        <video id="remote-video" autoplay></video>
        <video id="local-video" autoplay></video>
      </div>
    </div>

    <script type="text/javascript" src="web.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

连接到信令服务器

我们的 WebSocket 与 Web 应用监听同一台服务器,因此我们可以利用location.host包含主机名和端口的 来构建套接字 URL。连接后,我们需要登录,因为 WebSocket 不提供额外的身份验证方式:

// generates a username like "user42"
const randomUsername = `user${Math.floor(Math.random() * 100)}`;
const username = prompt("What's your name?", randomUsername);
const socketUrl = `ws://${location.host}/ws`;
const socket = new WebSocket(socketUrl);

// convenience method for sending json without calling JSON.stringify everytime
function sendMessageToSignallingServer(message) {
  const json = JSON.stringify(message);
  socket.send(json);
}

socket.addEventListener("open", () => {
  console.log("websocket connected");
  sendMessageToSignallingServer({
    channel: "login",
    name: username,
  });
});

socket.addEventListener("message", (event) => {
  const message = JSON.parse(event.data.toString());
  handleMessage(message);
});
Enter fullscreen mode Exit fullscreen mode

设置 WebRTC

现在,我们翘首以盼的 WebRTC 终于来了。JavaScript 中有一个RTCPeerConnection类,可以用来创建 WebRTC 连接。我们需要提供用于 ICE 候选发现的服务器,例如stun.stunprotocol.org

const webrtc = new RTCPeerConnection({
  iceServers: [
    {
      urls: [
        "stun:stun.stunprotocol.org",
      ],
    },
  ],
});

webrtc.addEventListener("icecandidate", (event) => {
  if (!event.candidate) {
    return;
  }

  // when we discover a candidate, send it to the other
  // party through the signalling server
  sendMessageToSignallingServer({
    channel: "webrtc_ice_candidate",
    candidate: event.candidate,
    otherPerson,
  });
});
Enter fullscreen mode Exit fullscreen mode

发送和接收媒体轨道

视频通话在有视频的情况下效果最佳,所以我们需要以某种方式发送视频流。这时,用户媒体 API 就派上用场了,它提供了一个函数来检索用户的网络摄像头流。

navigator
  .mediaDevices
  .getUserMedia({ video: true })
  .then((localStream) => {
    // display our local video in the respective tag
    const localVideo = document.getElementById("local-video");
    localVideo.srcObject = localStream;

    // our local stream can provide different tracks, e.g. audio and
    // video. even though we're just using the video track, we should
    // add all tracks to the webrtc connection
    for (const track of localStream.getTracks()) {
      webrtc.addTrack(track, localStream);
    }
  });

webrtc.addEventListener("track", (event) => {
  // we received a media stream from the other person. as we're sure 
  // we're sending only video streams, we can safely use the first
  // stream we got. by assigning it to srcObject, it'll be rendered
  // in our video tag, just like a normal video
  const remoteVideo = document.getElementById("remote-video");
  remoteVideo.srcObject = event.streams[0];
});
Enter fullscreen mode Exit fullscreen mode

执行 WebRTC 握手

我们的handleMessage函数严格遵循上面的序列图:当 Bob 收到start_call消息时,他会向信令服务器发送一个 WebRTC 请求。Alice 收到该请求后,会回复她的 WebRTC 应答,Bob 也会通过信令服务器收到该应答。完成后,双方交换 ICE 候选集。

WebRTC API 是围绕Promises 构建的,因此最简单的方法是声明一个async函数并await在其中:

// we'll need to have remember the other person we're calling,
// thus we'll store it in a global variable
let otherPerson;

async function handleMessage(message) {
  switch (message.channel) {
    case "start_call":
      // done by Bob: create a webrtc offer for Alice
      otherPerson = message.otherPerson;
      console.log(`receiving call from ${otherPerson}`);

      const offer = await webrtc.createOffer();
      await webrtc.setLocalDescription(offer);
      sendMessageToSignallingServer({
        channel: "webrtc_offer",
        offer,
        otherPerson,
      });
      break;

    case "webrtc_offer":
      // done by Alice: react to Bob's webrtc offer
      console.log("received webrtc offer");
      // we might want to create a new RTCSessionDescription
      // from the incoming offer, but as JavaScript doesn't
      // care about types anyway, this works just fine:
      await webrtc.setRemoteDescription(message.offer);

      const answer = await webrtc.createAnswer();
      await webrtc.setLocalDescription(answer);

      sendMessageToSignallingServer({
        channel: "webrtc_answer",
        answer,
        otherPerson,
      });
      break;

    case "webrtc_answer":
      // done by Bob: use Alice's webrtc answer
      console.log("received webrtc answer");
      await webrtc.setRemoteDescription(message.answer);
      break;

    case "webrtc_ice_candidate":
      // done by both Alice and Bob: add the other one's
      // ice candidates
      console.log("received ice candidate");
      // we could also "revive" this as a new RTCIceCandidate
      await webrtc.addIceCandidate(message.candidate);
      break;

    default:
      console.log("unknown message", message);
      break;
  }
}
Enter fullscreen mode Exit fullscreen mode

通过按钮开始通话

我们目前还缺少的关键功能是如何通过“呼叫某人”按钮发起呼叫。我们需要做的就是start_call向信令服务器发送一条消息,其他一切都将由 WebSocket 处理,并且handleMessage

const callButton = document.getElementById("call-button");
callButton.addEventListener("click", () => {
  otherPerson = prompt("Who you gonna call?");
  sendMessageToSignallingServer({
    channel: "start_call",
    otherPerson,
  });
});
Enter fullscreen mode Exit fullscreen mode

结论

如果我们同时在 Chrome 和 Safari 上打开该应用,就可以在不同的浏览器上调用我们自己的应用了。这太酷了!

除了调用之外,还有很多操作没有在这篇文章中涉及,例如清理连接,我可能会在以后的文章中介绍(例如使用 React Hooks 实现 WebRTC 和 WebSockets)。欢迎查看代码库,在那里你也可以追溯这篇文章中介绍的所有内容。感谢阅读!

鏂囩珷鏉ユ簮锛�https://dev.to/michaelneu/i-webrtc-you-building-a-video-chat-in-javascript-2j38
PREV
然后面试官问:“你能用更少的代码做到这一点吗?”
NEXT
技术债务简介(以及它为何会改变你的职业生涯)