基于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相关的知识,希望能给你带来一些帮助~

相关推荐
I_Am_Me_14 分钟前
【JavaEE进阶】 JavaScript
开发语言·javascript·ecmascript
雯0609~21 分钟前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ24 分钟前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z30 分钟前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
前端百草阁1 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜1 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4041 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish1 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple1 小时前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five1 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript