基于WebRtc和WebSocket实现视频聊天

前置知识

什么是 WebRtc ?

WebRTC(Real-Time Communication),是一个由 Google 发起的实时通讯解决方案,它既是 API 也是协议。WebRTC 协议是两个 WebRTC Agent 协商双向安全实时通信的一组规则,我们可以通过 WebRTC API 使用 WebRTC 协议,从而实现实时通信。

WebRTC 标准概括介绍了两种不同的技术:媒体捕获设备和点对点连接

媒体捕获设备就包括了包括摄像机,麦克风,和屏幕捕获设备,对于摄像头和麦克风,我们使用 navigator.mediaDevices.getUserMedia() 来捕获 MediaStreams。对于屏幕录制,我们改为使用 navigator.mediaDevices.getDisplayMedia()

点对点连接就由 RTCPeerConnection 接口处理,它代表一个由本地计算机到远端的 WebRTC 连接。该接口提供了创建,保持,监控,关闭连接的方法的实现。

什么是 WebSocket ?

它是一种协议,用于在 Web 应用程序中创建实时、双向的通信通道。它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

开始实践

这两个核心技术大致介绍了一下,现在我们就开始实现一个视频聊天!

问题分析

实现之前,我们先来分析一下我们需要解决哪些核心问题:

  1. 如何获取当前用户的视频和音频并且显示?
  2. 两个客户端如何互相建立联系,传递消息?
  3. 如何实时传递对方的音视频流然后显示?

实现思路

如何获取当前用户的视频和音频?

这个问题其实在我介绍 WebRtc 的时候已经给出了答案:这里我们需要使用 WebRTC 中的媒体捕获设备类型的 api ,并且我们这里需要使用到的设备是摄像头和麦克风,所以就是使用navigator.mediaDevices.getUserMedia() 来捕获当前用户的音视频,然后我们再使用video标签展示我们获取到的音视频流,代码如下:

jsx 复制代码
  // 当前视频语音流
  const [ stream, setStream ] = useState()
  // 当前用户使用的video标签的ref
  const myVideo = useRef()
  useEffect(()=>{
      // 获取当前用户音视频
    navigator.mediaDevices.getUserMedia({ 
        video: true, 
        audio: true 
    }).then((stream) => {
        // 将stream通过state存储
        setStream(stream)
        if(myVideo.current){
            // 将stream给video标签的srcObject,用来显示音视频流
            myVideo.current.srcObject = stream
        }			
    })
 })

这里有个地方可以注意一下: srcObject 是实验属性

两个客户端如何互相建立联系,传递消息?

整体来说就是当客户端1发起通话时,要去发消息告诉服务端并且带上所需数据,然后服务端传达,然后客户端2同意接通之后,也要发送消息给服务端并且带上所需数据,服务端再次传达给客户端1,流程大致如图:这里省去了数据的传递

服务端设计

首先,这里我们肯定需要一个服务,用来接收客户端的信息并且做一些信息的传递,我们可以新建一个 server.js 文件,然后用 express (用啥随意,我这里使用 express 是因为 socket.io 官网示例用的express)新建一个服务,然后这个服务需要用到 websocket(后文统一使用 ws 作为它的简写)来进行客户端和服务端的双向通信。

server.js 文件代码如下:

js 复制代码
const express = require("express")
const http = require("http")
const app = express()
const server = http.createServer(app)
// 新建一个ws实例
const io = require("socket.io")(server,
    {
        // 解决跨域问题,3000端口运行的是前端项目
        cors: {
            origin: "http://localhost:3000",
            methods: [ "GET", "POST" ]
        }
    }
)

// 当ws连接成功时,触发回调
io.on("connection", (socket) => {

    // 给连接上的客户端发送其对应的socketId(socketId可以理解成客户端socket的唯一标识)
    socket.emit("socketId", socket.id)
    
    // 接收到客户端的请求通话事件
    socket.on("callOhter", (data) => {
        // 根据传来的ohterId,将信息转发到对应的客户端(告诉对应客户端你被call了!)
        io.to(data.to).emit("beCalled", { 
            // 拨打方peer信号
            signal: data.signalData, 
            // 拨打方id
            from: data.from, 
            // 拨打方名字
            name: data.name 
        })
    })

    // 接收到用户的同意接通事件
    socket.on("answerCall", (data) => {
        // 根据传来的ohterId,将信息转发到对应的客户端(告诉对应客户端对方接听了!)
        io.to(data.to).emit("otherAccepted", { 
            // peer信号
            signal: data.signalData, 
            // 接收方id
            from: data.from, 
            // 接收方名字
            name: data.name 
        })
    })

    // 接到一方挂断电话的事件,通知双方都要关闭连接了
    socket.on("endCall",(data)=>{
        io.to(data.to).emit("bothCallEnd")
    })
})

// 在5000端口开启服务
server.listen(5000, () => console.log("server is running on port 5000"))

客户端设计

按照上述设计,我们客户端在用户发起通话和同意通话时也要做一些处理,主要就是和服务端通信以及带上所需数据,代码如下(我这里只写相关socket消息传递逻辑):

js 复制代码
import { useState,useRef,useEffect } from 'react'
import { Input,Typography,Button, message } from 'antd';
import { CopyOutlined,WhatsAppOutlined } from '@ant-design/icons'
import io from "socket.io-client"
import Peer from "simple-peer"
import './App.css';

// 建立客户端和5000端口的服务端的ws连接
const socket = io.connect('http://localhost:5000')

function App() {
  // ...
  useEffect(()=>{
    // ...
    // 连接上socket时获取socketId
    socket.on('socketId',(id)=>{
      // ... dosomething
      // 当收到服务端传来的通信请求(我被人通信啦!)
      socket.on("beCalled",(data)=>{
         //...  do something
      })
    })

    // 当收到服务端传来的结束通话信息
    socket.on("bothCallEnd",()=>{
        // ... dosomething
    })
  },[])

  // 接通电话
  const answerCall = () =>{
    // ... dosomething
    // 接通方告诉服务端 "我要接通电话" ,并且传递数据
    socket.emit("answerCall", {
        // 接通方peer信号
        signalData: data,
        // 拨打方socketId
        to:ohterInfo.ohterId,
        // 接通方socketId
        from:myId,
        // 接通方名字
        name:myName,
    })
  }

  // 挂断按钮回调
  const endCall = () =>{
    // 通知服务端要结束通话了
    socket.emit("endCall",{
      to:ohterInfo.ohterId
    })   
    // ... dosomething
  }

  // 打给对方
  const callOhter = () =>{
    // 向服务端发送通话请求并传递相关数据
    socket.emit("callOhter", {
        // 接通方socketId
        to: otherId,
        // 拨打方peer信号
        signalData: data,
        // 拨打方socketId
        from: myId,
        // 拨打方名字
        name: myName
    })

    // 对方成功接通事件触发 
    socket.on("otherAccepted",(data)=>{
      setAccepted(true)
      // 存储接通方信息
      setOhterInfo({
        name:data.name,
        ohterId:data.from,
        otherSignal:data.signal
      })
    })
  }

  return ...
}

export default App;

如何实时传递对方的音视频流然后显示?

这里其实我在介绍WebRtc的时候也给出大概的答案,其实我们就是要建立一个点对点连接,然后通过这个连接拿到我们的音视频流(后面就使用 stream 代替),那怎么建立一个点对点连接呢?介绍中也说了WebRtc的点对点连接就由 RTCPeerConnection 接口处理,我们这里直接使用simple-peer这个包来实现,感兴趣的可以看看它的文档。

这地方的核心思路就是,我们总共需要两个peer(拨打方一个peer,接通方一个peer) ,将peer1的signal信号传给peer2,将peer2的信号传给peer1,然后这两个peer建立了的一个点对点连接,然后就可以通过 peer.on("stream", (stream)=>{})拿到远程对方的stream,然后再将stream给到对应的video标签即可。

peer建立的流程如下图:

结合peer之后的客户端细代码如下(含详细注释解释):

js 复制代码
import { useState,useRef,useEffect } from 'react'
import { Input,Typography,Button, message } from 'antd';
import { CopyOutlined,WhatsAppOutlined } from '@ant-design/icons'
import io from "socket.io-client"
import Peer from "simple-peer"
import './App.css';

// 建立客户端和5000端口的服务端的ws连接
const socket = io.connect('http://localhost:5000')

function App() {
  //  当前视频语音流
  const [ stream, setStream ] = useState()
  // 是否接受通话
  const [ accepted, setAccepted ] = useState(false)
  // 是否为正在接通状态
  const [ panding, setPanding] = useState(false)
  // myVideo实例
  const myVideo = useRef()
  // otherVideo实例
  const otherVideo = useRef()
  // 我的名字
  const [ myName , setMyName] = useState('')
  // 拨打方信息
  const [ ohterInfo, setOhterInfo] = useState({})
  // 我的通话id
  const [ myId, setMyId ] = useState('') 
  // 对方的通话id
  const [ otherId, setOtherId ] = useState('') 
  // peer链接
  const webRtcConnectionRef = useRef()

  useEffect(()=>{
    // ...
    // 连接上socket时获取socketId
    socket.on('socketId',(id)=>{
      // 存储socketId,这是客户端的socket唯一标识,传递信息需要用到
      setMyId(id)
      // 当收到服务端传来的通信请求(我被人通信啦!)
      socket.on("beCalled",(data)=>{
        // 设置待接听状态为true
        setPanding(true)
        // 存储拨打方信息
        setOhterInfo({
          // 拨打方名字
          name:data.name,
          // 拨打方socketId
          ohterId:data.from,
          // 拨打方peer信号
          otherSignal:data.signal
        })
      })
    })

    // 当收到服务端传来的结束通话信息
    socket.on("bothCallEnd",()=>{
      // 就做结束通话的处理
      handleEndCall()
    })
  },[])

  // 复制自己的通话id(这里的通话id就是socketId)
  const onCopyId = () =>{
    if(myId){
      navigator.clipboard.writeText(myId).then(()=>{
        message.success('复制成功')
      })
    }else{
      message.error('通话id不存在')
    }
  }

  // 接通电话
  const answerCall = () =>{
    // 设置已接受状态
    setAccepted(true)
    // 新建一个peer实例
    const peer2 = new Peer({
        initiator: false,
        trickle: false,
        stream: stream
    })
    // 用ref存储当前peer实例
    webRtcConnectionRef.current = peer2
    
    peer2.on("signal",(data)=>{
        // 接通方的signal要传给拨打方的peer(通过我们的server)
        socket.emit("answerCall", {
            // 接通方peer信号
            signalData: data,
            // 拨打方socketId
            to:ohterInfo.ohterId,
            // 接通方socketId
            from:myId,
            // 接通方名字
            name:myName,
        })
    })

    // 获取远程的音视频流
    peer2.on("stream",(stream)=>{
      // 将音视频流展示在video标签上
      if(otherVideo.current){
        otherVideo.current.srcObject = stream
      }			
    })
    // 将拨打方的信号传给接收方的peer2(建立webrtc连接的过程)
    peer2.signal(ohterInfo.otherSignal)
  }

  // 挂断按钮回调
  const endCall = () =>{
    // 通知服务端要结束通话了
    socket.emit("endCall",{
      to:ohterInfo.ohterId
    })   
    // 自己也执行结束通话回调
    handleEndCall()
  }

  // 结束通话处理函数
  const handleEndCall = () =>{
    // 设置结束的各种状态
    setAccepted(false)
    setPanding(false)
    // 销毁webtrtc连接
    if(webRtcConnectionRef.current){
      webRtcConnectionRef.current.destroy()
    } 
  }

  // 打给对方
  const callOhter = () =>{
    // 建立一个webrtc链接
    const peer = new Peer({
        initiator: true,
        trickle: false,
        stream: stream
    })
    // 用ref保持这个链接
    webRtcConnectionRef.current = peer
    // 当peer配置initiator: true时,signal事件会立刻触发
    peer.on("signal", (data) => {
        // 向服务端发送通话请求
        socket.emit("callOhter", {
            // 接通方socketId
            to: otherId,
            // 拨打方peer信号
            signalData: data,
            // 拨打方socketId
            from: myId,
            // 拨打方名字
            name: myName
        })
    })

    // 获取远程的音视频流
    peer.on("stream", (stream) => {
      // 将音视频流展示在标签上
      if(otherVideo.current){
        otherVideo.current.srcObject = stream
      }		
    })

    // 解决挂断再次连接之后的cannot signal after peer is destroyed 报错
    peer.on('close', () => { socket.off("otherAccepted"); });

    // 对方成功接通事件 
    socket.on("otherAccepted",(data)=>{
      setAccepted(true)
      // 将接通方peer2的信号给拨打方peer,至此webrtc的通信建立,stream中开始流通数据
      peer.signal(data.signal)
      // 存储接通方信息
      setOhterInfo({
        name:data.name,
        ohterId:data.from,
        otherSignal:data.signal
      })
    })
  }

  return ... (jsx代码就省略了,相信这个大家都会写)
}

export default App;

最后大致效果如下图:

待接听状态效果图:

已接听状态效果图:

最后总结

本文主要基于WebRtc和WebSocket技术实现了一个可以视频聊天的web网页,其中涉及了WebRtc,WebScoket,node服务以及React相关的知识,希望能给你带来一些帮助~

相关推荐
李鸿耀5 分钟前
仅用几行 CSS,实现优雅的渐变边框效果
前端
码事漫谈25 分钟前
解决 Anki 启动器下载错误的完整指南
前端
im_AMBER1 小时前
Web 开发 27
前端·javascript·笔记·后端·学习·web
蓝胖子的多啦A梦1 小时前
低版本Chrome导致弹框无法滚动的解决方案
前端·css·html·chrome浏览器·版本不同造成问题·弹框页面无法滚动
玩代码1 小时前
vue项目安装chromedriver超时解决办法
前端·javascript·vue.js
訾博ZiBo1 小时前
React 状态管理中的循环更新陷阱与解决方案
前端
StarPrayers.2 小时前
旅行商问题(TSP)(2)(heuristics.py)(TSP 的两种贪心启发式算法实现)
前端·人工智能·python·算法·pycharm·启发式算法
一壶浊酒..2 小时前
ajax局部更新
前端·ajax·okhttp
苏打水com2 小时前
JavaScript 面试题标准答案模板(对应前文核心考点)
javascript·面试
Wx-bishekaifayuan3 小时前
基于微信小程序的社区图书共享平台设计与实现 计算机毕业设计源码44991
javascript·vue.js·windows·mysql·pycharm·tomcat·php