React+WebRTC简单实现房间视频通话demo

前置工作

先把demo搭好

注意前端我们需要依赖socket.io-client

后端我们需要依赖socket.io

来搭建信令服务器

等等

WebRTC (Web Real-Time Communication ) )是一种实时通信技术,它允许网络应用程序和站点在不使用中间服务器的情况下,在浏览器之间直接建立点对点(P2P)连接,提供了一些可以用在视频聊天,音频聊天或 P2P 文件分享等 Web App 中的 API

不是两个客户端之间可以直接建立点对点连接吗?我们还需要信令服务器干什么?

由于NAT(网络地址转换)和防火墙限制了设备之间的直接通信。我们需要信令服务器可以充当一个中继,帮助设备之间建立连接,使通信得以顺利进行

一旦两台设备的连接建立完成,在P2P网络中,设备就可以直接使用其他设备的IP地址和端口进行数据传输

所以信令服务器的作用在于协调P2P连接的建立,告知双方需要交换媒体类型和格式

那另一个问题,为什么要基于websocket协议而不是前端常用的http协议创建信令服务器?

试想客户端A和B试图建立连接,双方需要交换一些基本信息

这里涉及到服务端主动推送信息到B客户端,使用http协议来实现的话非常恶心(长/短轮询)

所以可以考虑使用基于websocketjssocket.io来实现

前端依赖socket.io-client

后端依赖socket.io

js 复制代码
// React + ts
yarn create vite

yarn add koa

// socket.io
yarn add socket.io-client
yarn add socket.io

// 创建后端目录
cd ./src
mkdir ./backend
cd ./backend
cd . > app.js
js 复制代码
// app.js
import Koa from 'koa'
const app = new Koa()

app.listen(3333, ()=>{
  console.log('后端跑在3333~')
})

package.json加上 script

js 复制代码
  "scripts": {
    "dev": "vite",
    "dev-back": "cd ./src/backend && node ./app.js",
    ...
    }

开发环境启动

js 复制代码
yarn dev
yarn dev-back

Step 1: 准备好客户端的id和房间id

回到正题,房间视频通话,房间视频通话

我们需要在哪几个地方下功夫?我,你和我们要加入的房间

写两个输入框用于表明我是谁,我要加入哪个房间

js 复制代码
  
 const [info, setInfo] = useState<IInfo>({
    myID: "",
    roomID: ""
  })
  const [hasRegister, setHasRegister] = useState<boolean>(false)

  const handleInput = (key: string, value: string) => {
    setInfo({ ...info, [key]: value })
  }
  const handleClick = () => {
      setHasRegister(!!(info.myID && info.roomID))
  }
  return (
    <>
      我的id:
      <input type="text" onInput={(e) => handleInput("myID", (e.target as HTMLInputElement).value)} />
      房间id:
      <input type='text' onInput={(e) => handleInput("roomID", (e.target as HTMLInputElement).value)} />
      <button onClick={handleClick} >斯达头!</button>
    </>
js 复制代码
export interface IInfo {
  myID: string,
  roomID: string
}

清晰明了

Step 2: 获取本地视频流并挂载到video标签上

感谢全能的浏览器提供了完善的API

js 复制代码
    const myVideoRef = useRef<HTMLVideoElement>(null)
    const myVideoStream = useRef<MediaStream | null>(null)
    

    const init = async () => {
        const stream = await navigator.mediaDevices.getUserMedia({ 
            audio: true,
            video: true 
        })
        myVideoStream.current = stream
        myVideoRef.current.srcObject = myVideoStream.current!
    }

    useEffect(() => {
        init()
    }, [])


    return <>
        <video ref={myVideoRef} width="200" height="200" autoPlay muted />
    </>

这里用到的API:navigator.mediaDevices.getUserMedia

  • getUserMedia- 为一个 RTC 连接获取设备的摄像头与 (或) 麦克风权限,并为此 RTC 连接接入设备的摄像头与 (或) 麦克风的信号。

欲了解更多可看 developer.mozilla.org/zh-CN/docs/...


哇哦,现在我们的脑袋出现在了屏幕上


Step 3: 在发出媒体流之前

在发出媒体流之前,需要找到发送方

并且发送方必须和我们在同一房间中

js 复制代码
import { Socket, io } from 'socket.io-client'

    // 维护一个和 websocket 服务器的连接
    const socketRef = useRef<Socket | null>(io('http://localhost:3333'))

    // 发起连接,告诉 websocket 服务器我们的id和所在房间的id
    const pushStream = async (stream: MediaStream) => {
        const socket = socketRef.current!
        if (!hasConnect) {
            socket.send({
                type: 'connect',
                data: {
                    myID: props.info.myID,
                    roomID: props.info.roomID
                }
            })
            setHasConnect(true)
        }
        


    }
}

在后端 app.js

js 复制代码
import Koa from 'koa'
import { Server } from 'socket.io'


// 创建服务器
const app = new Koa()


//创建 webSocket 服务
const io = new Server({
//设置跨域
  cors: {
    origin: ["http://localhost:5173", "http://localhost:5174"]
  }
});

//  存储连接上ws服务的客户端信息
const clients = {};

// 在 connection 完成后,持续监听 message
io.on("connection", function (ws) {
  ws.on("message", function (message) {
    let clientID
    let roomID
    switch (message.type) {
    // 如果 message 中携带的 type 是 connect
      case "connect":
        // 将连接的客户端存储起来
        clientID = message.data.myID;
        roomID = message.data.roomID
        console.log(`${clientID} has connected in ${roomID}`)
        // 存到 clients 对象里
        clients[clientID] = {
          clientID,
          roomID,
          ws,
        };
        ws.send(
          JSON.stringify({
            type: "connect",
          })
        );
        break;
       }
   })
})

现在服务端能感知到客户端的存在,并存储在服务端中,如果能感知到多个客户端,自然就能引导多个客户端之间建立p2p连接

Step 4: 建立p2p连接

在客户端,创建一个p2p连接实例peer

js 复制代码
    const peerRef = useRef<RTCPeerConnection | null>(new RTCPeerConnection())
    
    
      const pushStream = async (stream: MediaStream) => {
        ....

        // 把本地的音视频流先塞到 peer里,虽然连接还没建立
        stream.getTracks().forEach((track) => peer.addTrack(track))
        // 连接的必要条件1:准备好客户端A的offer,发给服务端
        const offer = await peer.createOffer()
        await peer.setLocalDescription(offer)
        socket.send({
            type: 'offer',
            data: {
                sdp: offer.sdp,
                myID: props.info.myID,
                roomID: props.info.roomID
            }
        })

    }

回到服务端, 如果我们接受到的是客户端的offer,我们需要把offer交给房间内的其他客户端

js 复制代码
      case "offer":
        sdp = message.data.sdp;
        clientID = message.data.myID;
        roomID = message.data.roomID;
        for (const client in clients) {
          if (isNotClientSelf(clients, client, roomID, clientID)) {
          // 触发客户端B的call事件
            clients[client].ws.emit('call', {
              data: sdp,
            })
          }
        }
        break;

再回到客户端, 我们需要监听call事件是否被服务端触发

js 复制代码
    useEffect(() => {
        const socket = socketRef.current!
        const peer = peerRef.current!
        // 接受到其他 client 的 call
        socket.on('call', async (res) => {
            const sdp = res.data
            // 建立p2p连接的必要条件2:B拿到客户端A的offer,并设置到自己的连接实例中
            await peer.setRemoteDescription({
                type: 'offer',
                sdp,
            })

            // 建立p2p连接的必要条件3: B设置完sdp,"answer"A
            peer.createAnswer().then(async answer => {
                await peer.setLocalDescription(answer)
                socket.send({
                    type: 'answer',
                    data: {
                        sdp: answer.sdp,
                        roomID: props.info.roomID,
                        myID: props.info.myID
                    }
                })
            })
        })

        return () => {
            socket.off('call')
            ....
        }

    }, [props.info])

再回到服务端...把来自B的answer发给A

js 复制代码
      case "answer":
        sdp = message.data.sdp;
        clientID = message.data.myID;
        roomID = message.data.roomID;
        for (const client in clients) {
          if (isNotClientSelf(clients, client, roomID, clientID)) {
            clients[client].ws.emit('answer', {
              data: sdp
            })
          }
        }
        break;

当p2p连接建立后,客户端A和B最后要交换 candidate

js 复制代码
       //一旦自己的candidate准备好了,就发给客户端
        peer.onicecandidate = (event) => {
            const candidate = event.candidate;
            if (candidate) {
                socket.send({
                    type: 'candidate',
                    data: {
                        candidate,
                        roomID: props.info.roomID,
                        myID: props.info.myID
                    }
                })
            }
        }
        
       // 一旦自己收到的对方的candidate,就加入自己的p2p连接中
        socket.on('candidate', async (res) => {
            const candidate = res.data
            await peer.addIceCandidate(new RTCIceCandidate(candidate))
        })

服务端作为交换candidate的中间桥梁

js 复制代码
      case "candidate":
       candidate = message.data.candidate;
       clientID = message.data.myID;
       roomID = message.data.roomID;
       for (const client in clients) {
         if (isNotClientSelf(clients, client, roomID, clientID)) {
           clients[client].ws.emit('candidate', {
             data: candidate
           })
         }
       }
       break;

Step 5: 挂载对方视频到自己的Video标签上

js 复制代码
        // 一旦执行完上面的步骤,客户端A就可以拿到客户端B的视频流了
        // ontrack监听到就挂到video标签上即可
        peer.ontrack = (e) => {
            yourVideoStream.current!.addTrack(e.track)
            yourVideoRef.current.srcObject = yourVideoStream.current!
        }

PS

还可以在服务端加上一个定时器,

周期性同步一些数据给房间内其他客户端(如房间人数,房间内客户端有哪些...)

js 复制代码
setInterval(() => {
  for (const client in clients) {
    clients[client].ws.emit('updateAllClients', {
      data: .....
    })
  }
}, 2000)

我们也可以使用socket.io的api来实现房间的效果,这里就不再赘述了,

可见 socket.io/docs/v4/roo...

相关推荐
ZHOU_WUYI2 小时前
使用Docker部署React应用与Nginx
nginx·react.js·docker
肠胃炎2 小时前
React Contxt详解
javascript·react.js·ecmascript
xx24062 小时前
React Native简介
javascript·react native·react.js
寧笙(Lycode)7 小时前
React系列——nvm、node、npm、yarn(MAC)
react.js·macos·npm
霸王蟹13 小时前
React中巧妙使用异步组件Suspense优化页面性能。
前端·笔记·学习·react.js·前端框架
Coding的叶子13 小时前
React Flow 节点属性详解:类型、样式与自定义技巧
react.js·node·节点·fgai·react agent
霸王蟹14 小时前
React 19 中的useRef得到了进一步加强。
前端·javascript·笔记·学习·react.js·ts
霸王蟹14 小时前
React 19版本refs也支持清理函数了。
前端·javascript·笔记·react.js·前端框架·ts
ZHOU_WUYI15 小时前
React与Docker中的MySQL进行交互
mysql·react.js·docker
霸王蟹18 小时前
React 19中如何向Vue那样自定义状态和方法暴露给父组件。
前端·javascript·学习·react.js·typescript