JS `WebRTC`:点对点音视频通信与数据通道

各位好,我是老码,今天咱们来聊聊WebRTC这玩意儿,这可是前端领域里为数不多的硬骨头之一,啃下来那叫一个香!

今天咱们的目标是:搞明白WebRTC是怎么实现点对点音视频通信和数据传输的。别怕,咱们不搞那些深奥的理论,直接上干货,代码说话!

一、WebRTC是个啥? 简单来说就是个“媒婆”

WebRTC,全称Web Real-Time Communication,直译过来就是“网页实时通信”。 这玩意儿可不是让你在浏览器里写QQ, 而是让你在浏览器里直接搞音视频聊天,文件传输,甚至游戏!

你可以把WebRTC想象成一个超级媒婆,它不负责帮你谈恋爱,但它负责帮你找到对象,然后让你们俩直接对话,它自己就功成身退了。

WebRTC的核心功能主要有三个:

  1. 获取音视频流(getUserMedia): 允许浏览器访问用户的摄像头和麦克风。
  2. 点对点连接(Peer-to-Peer Connection): 在浏览器之间建立直接的连接,减少延迟。
  3. 数据通道(Data Channel): 在浏览器之间传输任意数据,比如文本,文件等。

二、getUserMedia:先露个脸,亮个嗓

要聊天,首先得让对方看到你,听到你。 getUserMedia 就是干这事的,它会弹出个窗口,问你愿不愿意让浏览器访问你的摄像头和麦克风。

// 先检查浏览器是否支持getUserMedia
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
  // 定义请求的媒体类型(音视频)
  const constraints = {
    audio: true, // 允许访问麦克风
    video: { width: 640, height: 480 } // 允许访问摄像头,并指定分辨率
  };

  navigator.mediaDevices.getUserMedia(constraints)
    .then(function(stream) {
      // 获取到媒体流,把它显示在video标签里
      const video = document.querySelector('video');
      video.srcObject = stream;
      video.onloadedmetadata = function(e) {
        video.play();
      };
    })
    .catch(function(err) {
      // 用户拒绝授权,或者发生其他错误
      console.log("访问媒体设备失败: " + err.name + ": " + err.message);
    });
} else {
  console.log("getUserMedia is not supported");
}

这段代码做了几件事:

  • 检查支持性: 看看浏览器是不是支持 getUserMedia,不支持就直接退出,省的白费力气。
  • 定义约束: 告诉浏览器你想访问哪些媒体设备,以及一些参数,比如分辨率。
  • 请求授权: 调用 getUserMedia 方法,浏览器会弹窗询问用户是否授权。
  • 处理成功: 如果用户授权成功,你会得到一个 MediaStream 对象,这个对象包含了音视频数据,你可以把它赋值给一个 <video> 标签的 srcObject 属性,让视频显示出来。
  • 处理失败: 如果用户拒绝授权,或者发生了其他错误,catch 块会捕获错误,并输出到控制台。

三、PeerConnection:连接你我,心连心

RTCPeerConnection 是WebRTC的核心,它负责建立浏览器之间的点对点连接。 这个过程比较复杂,涉及到信令交换(Signaling),NAT穿透(Network Address Translation Traversal),ICE协商(Interactive Connectivity Establishment)等技术。

别慌,咱们一步步来。

1. 信令交换(Signaling)

WebRTC本身并不负责信令交换,你需要自己搭建一个信令服务器,用来交换一些信息,比如:

  • 会话描述(Session Description): 包括音视频编解码器,传输协议等信息。
  • ICE候选者(ICE Candidates): 包含了客户端的网络地址信息,用于NAT穿透。

信令服务器可以使用任何技术,比如WebSocket, Socket.IO, HTTP长轮询等。

咱们先假设你已经有了一个信令服务器,并且已经实现了基本的消息传递功能。

2. 创建PeerConnection对象

// 创建PeerConnection对象
const configuration = {
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] // 使用Google的STUN服务器
};

const peerConnection = new RTCPeerConnection(configuration);

这里创建了一个 RTCPeerConnection 对象,并配置了一个STUN服务器。 STUN服务器的作用是帮助客户端发现自己的公网IP地址和端口,用于NAT穿透。

3. 创建Offer (发起方)

发起方需要创建一个 Offer,描述自己的音视频能力。

peerConnection.createOffer()
  .then(function(offer) {
    // 设置本地会话描述
    return peerConnection.setLocalDescription(offer);
  })
  .then(function() {
    // 将Offer发送给对方(通过信令服务器)
    sendMessage({ type: 'offer', sdp: peerConnection.localDescription });
  })
  .catch(function(err) {
    console.log("创建Offer失败: " + err.message);
  });
  • createOffer() 方法会创建一个SDP (Session Description Protocol) 对象,描述了自己的音视频能力。
  • setLocalDescription() 方法会将这个SDP设置为本地会话描述。
  • 然后你需要将这个SDP发送给对方,告诉对方你的能力。

4. 接收Answer (接收方)

接收方收到Offer后,需要设置远程会话描述,并创建Answer。

// 收到Offer消息
function handleOffer(message) {
  peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp))
    .then(function() {
      // 创建Answer
      return peerConnection.createAnswer();
    })
    .then(function(answer) {
      // 设置本地会话描述
      return peerConnection.setLocalDescription(answer);
    })
    .then(function() {
      // 将Answer发送给对方(通过信令服务器)
      sendMessage({ type: 'answer', sdp: peerConnection.localDescription });
    })
    .catch(function(err) {
      console.log("处理Offer失败: " + err.message);
    });
}
  • setRemoteDescription() 方法会将收到的Offer设置为远程会话描述。
  • createAnswer() 方法会创建一个Answer,描述了自己的音视频能力,并和Offer进行协商。
  • setLocalDescription() 方法会将这个Answer设置为本地会话描述。
  • 然后你需要将这个Answer发送给对方,告诉对方你的能力。

5. 设置远程会话描述 (发起方)

发起方收到Answer后,需要设置远程会话描述。

// 收到Answer消息
function handleAnswer(message) {
  peerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp))
    .catch(function(err) {
      console.log("处理Answer失败: " + err.message);
    });
}

6. 交换ICE Candidates

在建立连接的过程中,双方需要交换ICE Candidates,用于NAT穿透。

// 监听icecandidate事件
peerConnection.onicecandidate = function(event) {
  if (event.candidate) {
    // 将ICE Candidate发送给对方(通过信令服务器)
    sendMessage({ type: 'candidate', candidate: event.candidate });
  }
};

// 收到ICE Candidate消息
function handleCandidate(message) {
  const candidate = new RTCIceCandidate(message.candidate);
  peerConnection.addIceCandidate(candidate)
    .catch(function(err) {
      console.log("添加ICE Candidate失败: " + err.message);
    });
}
  • onicecandidate 事件会在收集到新的ICE Candidate时触发。
  • 你需要将ICE Candidate发送给对方,让对方添加到自己的PeerConnection对象中。
  • addIceCandidate() 方法会将ICE Candidate添加到PeerConnection对象中。

7. 添加媒体流

连接建立成功后,需要将媒体流添加到PeerConnection对象中。

// 将本地媒体流添加到PeerConnection对象
localStream.getTracks().forEach(track => {
  peerConnection.addTrack(track, localStream);
});

// 监听ontrack事件,当收到远程媒体流时触发
peerConnection.ontrack = function(event) {
  const remoteVideo = document.querySelector('#remoteVideo');
  remoteVideo.srcObject = event.streams[0];
};
  • addTrack() 方法会将媒体流的轨道添加到PeerConnection对象中。
  • ontrack 事件会在收到远程媒体流时触发。
  • 你可以将远程媒体流赋值给一个 <video> 标签的 srcObject 属性,让视频显示出来。

四、Data Channel:不只是聊天,还能传文件!

WebRTC的Data Channel允许你在浏览器之间传输任意数据,比如文本,文件,游戏数据等。

1. 创建Data Channel

// 创建Data Channel (发起方)
const dataChannel = peerConnection.createDataChannel('myChannel', {
  ordered: true, // 保证消息的顺序
  maxRetransmits: 3 // 最大重传次数
});

dataChannel.onopen = function() {
  console.log("Data Channel已打开");
};

dataChannel.onmessage = function(event) {
  console.log("收到消息: " + event.data);
};

dataChannel.onerror = function(error) {
  console.log("Data Channel发生错误: " + error);
};

dataChannel.onclose = function() {
  console.log("Data Channel已关闭");
};

// 监听ondatachannel事件 (接收方)
peerConnection.ondatachannel = function(event) {
  const dataChannel = event.channel;

  dataChannel.onopen = function() {
    console.log("Data Channel已打开");
  };

  dataChannel.onmessage = function(event) {
    console.log("收到消息: " + event.data);
  };

  dataChannel.onerror = function(error) {
    console.log("Data Channel发生错误: " + error);
  };

  dataChannel.onclose = function() {
    console.log("Data Channel已关闭");
  };
};
  • createDataChannel() 方法会创建一个Data Channel。
  • ondatachannel 事件会在收到对方创建的Data Channel时触发。
  • ordered 选项可以保证消息的顺序。
  • maxRetransmits 选项可以设置最大重传次数。

2. 发送数据

// 发送消息
dataChannel.send("Hello, world!");
  • send() 方法可以发送数据。

五、 完整代码示例 (简化版)

为了方便理解,这里提供一个简化版的代码示例,只包含核心逻辑,省略了信令服务器的实现。

发起方 (Caller):

<!DOCTYPE html>
<html>
<head>
  <title>WebRTC Demo (Caller)</title>
</head>
<body>
  <h1>Caller</h1>
  <video id="localVideo" autoplay muted></video>
  <video id="remoteVideo" autoplay></video>
  <button id="startButton">Start</button>
  <button id="callButton">Call</button>
  <button id="hangupButton">Hangup</button>

  <script>
    const startButton = document.querySelector('#startButton');
    const callButton = document.querySelector('#callButton');
    const hangupButton = document.querySelector('#hangupButton');
    const localVideo = document.querySelector('#localVideo');
    const remoteVideo = document.querySelector('#remoteVideo');

    let localStream;
    let peerConnection;
    let dataChannel;

    startButton.addEventListener('click', start);
    callButton.addEventListener('click', call);
    hangupButton.addEventListener('click', hangup);

    async function start() {
      try {
        localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
        localVideo.srcObject = localStream;
      } catch (e) {
        console.error('getUserMedia() error:', e);
      }
    }

    async function call() {
      callButton.disabled = true;
      startButton.disabled = true;

      const configuration = {
        iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
      };
      peerConnection = new RTCPeerConnection(configuration);

      peerConnection.onicecandidate = event => {
        console.log('ICE candidate:', event.candidate);
        //  在这里你需要通过信令服务器将candidate发送给Callee
        //  这里为了简化,直接使用console.log模拟
      };

      peerConnection.ontrack = event => {
        remoteVideo.srcObject = event.streams[0];
      };

      localStream.getTracks().forEach(track => {
        peerConnection.addTrack(track, localStream);
      });

      // 创建Data Channel
      dataChannel = peerConnection.createDataChannel('myChannel', {
        ordered: true,
        maxRetransmits: 3
      });

      dataChannel.onopen = () => {
        console.log("Data Channel opened");
        dataChannel.send("Hello from Caller!");
      };

      dataChannel.onmessage = event => {
        console.log("Message from Data Channel: " + event.data);
      };

      dataChannel.onerror = error => {
        console.error("Data Channel error:", error);
      };

      dataChannel.onclose = () => {
        console.log("Data Channel closed");
      };

      try {
        const offer = await peerConnection.createOffer();
        await peerConnection.setLocalDescription(offer);
        console.log('Created offer:', offer);
        //  在这里你需要通过信令服务器将offer发送给Callee
        //  这里为了简化,直接使用console.log模拟
      } catch (e) {
        console.error('Failed to create offer:', e);
      }
    }

    function hangup() {
      console.log('Ending call');
      if (peerConnection) {
        peerConnection.close();
      }

      hangupButton.disabled = true;
      callButton.disabled = false;
      startButton.disabled = false;
    }

  </script>
</body>
</html>

接收方 (Callee):

<!DOCTYPE html>
<html>
<head>
  <title>WebRTC Demo (Callee)</title>
</head>
<body>
  <h1>Callee</h1>
  <video id="localVideo" autoplay muted></video>
  <video id="remoteVideo" autoplay></video>
  <button id="startButton">Start</button>
  <button id="answerButton">Answer</button>
  <button id="hangupButton">Hangup</button>

  <script>
    const startButton = document.querySelector('#startButton');
    const answerButton = document.querySelector('#answerButton');
    const hangupButton = document.querySelector('#hangupButton');
    const localVideo = document.querySelector('#localVideo');
    const remoteVideo = document.querySelector('#remoteVideo');

    let localStream;
    let peerConnection;

    startButton.addEventListener('click', start);
    answerButton.addEventListener('click', answer);
    hangupButton.addEventListener('click', hangup);

    async function start() {
      try {
        localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
        localVideo.srcObject = localStream;
      } catch (e) {
        console.error('getUserMedia() error:', e);
      }
    }

    async function answer() {
      answerButton.disabled = true;
      startButton.disabled = true;

      const configuration = {
        iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
      };
      peerConnection = new RTCPeerConnection(configuration);

      peerConnection.onicecandidate = event => {
        console.log('ICE candidate:', event.candidate);
        //  在这里你需要通过信令服务器将candidate发送给Caller
        //  这里为了简化,直接使用console.log模拟
      };

      peerConnection.ontrack = event => {
        remoteVideo.srcObject = event.streams[0];
      };

      localStream.getTracks().forEach(track => {
        peerConnection.addTrack(track, localStream);
      });

      peerConnection.ondatachannel = event => {
        const dataChannel = event.channel;

        dataChannel.onopen = () => {
          console.log("Data Channel opened");
        };

        dataChannel.onmessage = event => {
          console.log("Message from Data Channel: " + event.data);
        };

        dataChannel.onerror = error => {
          console.error("Data Channel error:", error);
        };

        dataChannel.onclose = () => {
          console.log("Data Channel closed");
        };
      };

      //  在这里你需要通过信令服务器接收到Caller发送的offer
      //  这里为了简化,直接使用硬编码模拟
      const offer = {
        type: 'offer',
        sdp: '请将Caller控制台输出的offer.sdp复制到这里'
      };

      try {
        await peerConnection.setRemoteDescription(offer);
        const answer = await peerConnection.createAnswer();
        await peerConnection.setLocalDescription(answer);
        console.log('Created answer:', answer);
        //  在这里你需要通过信令服务器将answer发送给Caller
        //  这里为了简化,直接使用console.log模拟
      } catch (e) {
        console.error('Failed to create answer:', e);
      }
    }

    function hangup() {
      console.log('Ending call');
      if (peerConnection) {
        peerConnection.close();
      }

      hangupButton.disabled = true;
      answerButton.disabled = false;
      startButton.disabled = false;
    }

  </script>
</body>
</html>

使用方法:

  1. 分别打开 Caller.htmlCallee.html 两个页面。
  2. 在两个页面分别点击 "Start" 按钮,允许访问摄像头和麦克风。
  3. 复制 Caller 控制台输出的 offer.sdp 内容。
  4. 粘贴到 Callee 代码中 offer 对象的 sdp 属性值的位置。
  5. Callee 页面点击 "Answer" 按钮。
  6. 如果一切顺利,你应该可以看到两个页面互相显示对方的视频了,并且在控制台中可以看到Data Channel的消息。

注意:

  • 这个示例没有实现信令服务器,你需要自己搭建一个,或者使用一些现成的信令服务器解决方案,比如 Socket.IO, Firebase 等。
  • 由于没有信令服务器,ICE Candidates也无法交换,所以NAT穿透可能会失败,导致无法建立连接。

六、WebRTC的优势与劣势

特性 优势 劣势
点对点连接 延迟低,实时性高,适用于实时音视频通信和数据传输。 NAT穿透复杂,需要STUN/TURN服务器辅助,成功率受网络环境影响。
浏览器支持 现代浏览器普遍支持,无需安装插件。 不同浏览器和版本可能存在兼容性问题,需要进行适配。
安全性 使用DTLS和SRTP等加密协议,保证通信安全。 需要注意信令服务器的安全,防止中间人攻击。
灵活性 可以自定义音视频编解码器,传输协议,以及数据通道的参数。 配置复杂,需要对WebRTC的底层原理有一定的了解。
扩展性 可以通过Data Channel实现各种应用,比如文件传输,游戏数据传输,远程控制等。 对网络带宽和设备性能有一定要求,在高并发场景下需要进行优化。

七、总结

WebRTC 是一项强大的技术,可以让你在浏览器中实现实时音视频通信和数据传输。 虽然学习曲线比较陡峭,但只要掌握了核心概念和API,就可以开发出各种有趣的应用。

希望今天的分享对你有所帮助,下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注