使用 React、Node、WebRTC(peerjs) 进行视频聊天和屏幕共享

2025-05-24

使用 React、Node、WebRTC(peerjs) 进行视频聊天和屏幕共享

创建视频聊天和屏幕共享应用程序需要三个主要设置

  1. 用于处理 UI 的基本 React 设置。

  2. 需要后端(Nodejs)来维持套接字连接。

  3. 需要一个对等服务器来维护创建对等连接并维护它。

1)使用加入按钮进行 React 基本设置,该按钮向后端发出 API 调用并获取唯一 ID,然后重定向用户加入房间(React 在端口 3000 上运行)

前端 - ./Home.js

import Axios from 'axios';
import React from 'react';

function Home(props) {
    const handleJoin = () => {
        Axios.get(`http://localhost:5000/join`).then(res => {
            props.history?.push(`/join/${res.data.link}? 
           quality=${quality}`);
        })
    }

    return (
        <React.Fragment>
            <button onClick={handleJoin}>join</button>
        </React.Fragment>
    )
}

export default Home;
Enter fullscreen mode Exit fullscreen mode

这里我们的后端在端口 localhost 5000 上运行,作为响应将获得一个唯一的 ID,该 ID 将在接下来的步骤中用作房间 ID。

2)后端 - 节点基本设置,服务器监听端口 5000,并使用“/join”定义路由器以生成唯一 ID 并将其返回到前端

后端 - ./server.js

import express from 'express';
import cors from 'cors';
import server from 'http';
import { v4 as uuidV4 } from 'uuid';

const app = express();
const serve = server.Server(app);
const port = process.env.PORT || 5000;

// Middlewares
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.get('/join', (req, res) => {
    res.send({ link: uuidV4() });
});

serve.listen(port, () => {
    console.log(`Listening on the port ${port}`);
}).on('error', e => {
    console.error(e);
});
Enter fullscreen mode Exit fullscreen mode

这里使用 uuid 包来生成一个唯一的字符串。

3) 在前端创建一个新的路由,路由 ID 为响应中的值(类似于“ http://localhost:3000/join/a7dc3a79-858b-420b-a9c3-55eec5cf199b”)。创建一个新的组件 - RoomComponent,其中包含一个断开连接按钮,以及一个 id="room-container" 的 div 容器,用于存放视频元素。

前端 - ../RoomComponent.js

const RoomComponent = (props) => {
    const handleDisconnect = () => {
        socketInstance.current?.destoryConnection();
        props.history.push('/');
    }
    return (
        <React.Fragment>
            <div id="room-container"></div>
            <button onClick={handleDisconnect}>Disconnect</button>
        </React.Fragment>
    )
}

export default RoomComponent;
Enter fullscreen mode Exit fullscreen mode

4) 现在我们需要从设备摄像头和麦克风获取流,我们可以使用导航器获取设备流数据。为此,我们可以使用一个辅助类 (Connection) 来维护所有传入和传出的流数据,并维护与后端的套接字连接。

前端 - ./connection.js

import openSocket from 'socket.io-client';
import Peer from 'peerjs';
const { websocket, peerjsEndpoint } = env_config;
const initializePeerConnection = () => {
    return new Peer('', {
        host: peerjsEndpoint, // need to provide peerjs server endpoint 
                              // (something like localhost:9000)
        secure: true
    });
}
const initializeSocketConnection = () => {
    return openSocket.connect(websocket, {// need to provide backend server endpoint 
                              // (ws://localhost:5000) if ssl provided then
                              // (wss://localhost:5000) 
        secure: true, 
        reconnection: true, 
        rejectUnauthorized: false,
        reconnectionAttempts: 10
    });
}
class Connection {
    videoContainer = {};
    message = [];
    settings;
    streaming = false;
    myPeer;
    socket;
    myID = '';
    constructor(settings) {
        this.settings = settings;
        this.myPeer = initializePeerConnection();
        this.socket = initializeSocketConnection();
        this.initializeSocketEvents();
        this.initializePeersEvents();
    }
    initializeSocketEvents = () => {
        this.socket.on('connect', () => {
            console.log('socket connected');
        });
        this.socket.on('user-disconnected', (userID) => {
            console.log('user disconnected-- closing peers', userID);
            peers[userID] && peers[userID].close();
            this.removeVideo(userID);
        });
        this.socket.on('disconnect', () => {
            console.log('socket disconnected --');
        });
        this.socket.on('error', (err) => {
            console.log('socket error --', err);
        });
    }
    initializePeersEvents = () => {
        this.myPeer.on('open', (id) => {
            this.myID = id;
            const roomID = window.location.pathname.split('/')[2];
            const userData = {
                userID: id, roomID
            }
            console.log('peers established and joined room', userData);
            this.socket.emit('join-room', userData);
            this.setNavigatorToStream();
        });
        this.myPeer.on('error', (err) => {
            console.log('peer connection error', err);
            this.myPeer.reconnect();
        })
    }
    setNavigatorToStream = () => {
        this.getVideoAudioStream().then((stream) => {
            if (stream) {
                this.streaming = true;
                this.createVideo({ id: this.myID, stream });
                this.setPeersListeners(stream);
                this.newUserConnection(stream);
            }
        })
    }
    getVideoAudioStream = (video=true, audio=true) => {
        let quality = this.settings.params?.quality;
        if (quality) quality = parseInt(quality);
        const myNavigator = navigator.mediaDevices.getUserMedia || 
        navigator.mediaDevices.webkitGetUserMedia || 
        navigator.mediaDevices.mozGetUserMedia || 
        navigator.mediaDevices.msGetUserMedia;
        return myNavigator({
            video: video ? {
                frameRate: quality ? quality : 12,
                noiseSuppression: true,
                width: {min: 640, ideal: 1280, max: 1920},
                height: {min: 480, ideal: 720, max: 1080}
            } : false,
            audio: audio,
        });
    }
    createVideo = (createObj) => {
        if (!this.videoContainer[createObj.id]) {
            this.videoContainer[createObj.id] = {
                ...createObj,
            };
            const roomContainer = document.getElementById('room-container');
            const videoContainer = document.createElement('div');
            const video = document.createElement('video');
            video.srcObject = this.videoContainer[createObj.id].stream;
            video.id = createObj.id;
            video.autoplay = true;
            if (this.myID === createObj.id) video.muted = true;
            videoContainer.appendChild(video)
            roomContainer.append(videoContainer);
        } else {
            // @ts-ignore
            document.getElementById(createObj.id)?.srcObject = createObj.stream;
        }
    }
    setPeersListeners = (stream) => {
        this.myPeer.on('call', (call) => {
            call.answer(stream);
            call.on('stream', (userVideoStream) => {console.log('user stream data', 
            userVideoStream)
                this.createVideo({ id: call.metadata.id, stream: userVideoStream });
            });
            call.on('close', () => {
                console.log('closing peers listeners', call.metadata.id);
                this.removeVideo(call.metadata.id);
            });
            call.on('error', () => {
                console.log('peer error ------');
                this.removeVideo(call.metadata.id);
            });
            peers[call.metadata.id] = call;
        });
    }
    newUserConnection = (stream) => {
        this.socket.on('new-user-connect', (userData) => {
            console.log('New User Connected', userData);
            this.connectToNewUser(userData, stream);
        });
    }
    connectToNewUser(userData, stream) {
        const { userID } = userData;
        const call = this.myPeer.call(userID, stream, { metadata: { id: this.myID }});
        call.on('stream', (userVideoStream) => {
            this.createVideo({ id: userID, stream: userVideoStream, userData });
        });
        call.on('close', () => {
            console.log('closing new user', userID);
            this.removeVideo(userID);
        });
        call.on('error', () => {
            console.log('peer error ------')
            this.removeVideo(userID);
        })
        peers[userID] = call;
    }
    removeVideo = (id) => {
        delete this.videoContainer[id];
        const video = document.getElementById(id);
        if (video) video.remove();
    }
    destoryConnection = () => {
        const myMediaTracks = this.videoContainer[this.myID]?.stream.getTracks();
        myMediaTracks?.forEach((track:any) => {
            track.stop();
        })
        socketInstance?.socket.disconnect();
        this.myPeer.destroy();
    }
}

export function createSocketConnectionInstance(settings={}) {
    return socketInstance = new Connection(settings);
}
Enter fullscreen mode Exit fullscreen mode

这里我们创建了一个 Connection 类来维护我们所有的套接字和对等连接,不用担心,我们将介绍上述所有功能。

  1. 我们有一个构造函数,它获取一个设置对象(可选),该设置对象可用于从我们的组件发送一些数据来设置我们的连接类,例如(发送要使用的视频帧)
  2. 在构造函数中,我们调用两个方法:initializeSocketEvents() 和 initialisePeersEvents()
    • initialiseSocketEvents() - 将启动与后端的套接字连接。
    • initializePeersEvents() - 将启动与对等服务器的对等连接。
  3. 然后我们用 setNavigatorToStream() 来设置,它有一个 getVideoAndAudio() 函数,可以从导航器中获取音频和视频流。我们可以在导航器中指定视频帧速率。
  4. 如果流可用,那么我们将在 .then(streamObj) 中解析,现在我们可以创建一个视频元素来显示我们的流,绕过流对象来 createVideo()。
  5. 现在,在获得我们自己的流之后,是时候在函数 setPeersListeners() 中监听对等事件了,我们将监听来自另一个用户的任何传入视频流,并将我们的数据流传输到 peer.answer(ourStream)。
  6. 如果我们正在连接到现有房间,我们将设置 newUserConnection(),我们将在其中发送我们的流,并通过 peers 对象中的 userID 跟踪当前对等连接。
  7. 最后,当任何用户断开连接时,我们使用 removeVideo 从 dom 中删除视频元素。

5) 现在后端需要监听套接字连接。使用套接字“socket.io”可以轻松实现套接字连接。

后端 - ./server.js

import socketIO from 'socket.io';
io.on('connection', socket => {
    console.log('socket established')
    socket.on('join-room', (userData) => {
        const { roomID, userID } = userData;
        socket.join(roomID);
        socket.to(roomID).broadcast.emit('new-user-connect', userData);
        socket.on('disconnect', () => {
            socket.to(roomID).broadcast.emit('user-disconnected', userID);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

现在,我们已添加套接字连接到后端,用于监听加入房间的请求。该请求将由前端触发,并传入包含房间 ID 和用户 ID 的用户数据 (userData)。创建对等连接时,用户 ID 可用。

然后套接字现在已经连接到具有 roomID 的房间(从前端响应中获得的唯一 ID),现在我们可以向房间中的所有用户发送消息。

现在,通过 socket.to(roomID).broadcast.emit('new-user-connect', userData); ,我们可以向除我们之外的所有已连接用户发送消息。并且这个 'new-user-connect' 函数会在前端监听,因此所有已连接房间的用户都会收到新的用户数据。

6)现在您需要使用以下命令创建一个 peerjs 服务器

npm i -g peerjs
peerjs --port 9000
Enter fullscreen mode Exit fullscreen mode

7) 现在,我们需要在 Room 组件中调用 Connection 类来发起通话。请在 Room 组件中添加此功能。

前端 - ./RoomComponent.js

    let socketInstance = useRef(null);    
    useEffect(() => {
        startConnection();
    }, []);
    const startConnection = () => {
        params = {quality: 12}
        socketInstance.current = createSocketConnectionInstance({
            params
        });
    }
Enter fullscreen mode Exit fullscreen mode

现在您将能够看到,创建房间后,当新用户加入时,该用户将进行点对点连接。

8)现在对于屏幕共享,您需要用新的屏幕共享流替换当前流。

前端 - ./connection.js

    reInitializeStream = (video, audio, type='userMedia') => {
        const media = type === 'userMedia' ? this.getVideoAudioStream(video, audio) : 
        navigator.mediaDevices.getDisplayMedia();
        return new Promise((resolve) => {
            media.then((stream) => {
                if (type === 'displayMedia') {
                    this.toggleVideoTrack({audio, video});
                }
                this.createVideo({ id: this.myID, stream });
                replaceStream(stream);
                resolve(true);
            });
        });
    }
    toggleVideoTrack = (status) => {
        const myVideo = this.getMyVideo();
        if (myVideo && !status.video) 
            myVideo.srcObject?.getVideoTracks().forEach((track) => {
                if (track.kind === 'video') {
                    !status.video && track.stop();
                }
            });
        else if (myVideo) {
            this.reInitializeStream(status.video, status.audio);
        }
    }
    replaceStream = (mediaStream) => {
        Object.values(peers).map((peer) => {
            peer.peerConnection?.getSenders().map((sender) => {
                if(sender.track.kind == "audio") {
                    if(mediaStream.getAudioTracks().length > 0){
                        sender.replaceTrack(mediaStream.getAudioTracks()[0]);
                    }
                }
                if(sender.track.kind == "video") {
                    if(mediaStream.getVideoTracks().length > 0){
                        sender.replaceTrack(mediaStream.getVideoTracks()[0]);
                    }
                }
            });
        })
    }
Enter fullscreen mode Exit fullscreen mode

现在当前流需要 reInitializeStream() 检查它需要替换的类型,如果是 userMedia 那么它将从摄像头和麦克风进行流式传输,如果是显示媒体它将从 getDisplayMedia() 获取显示流对象然后它将切换轨道以停止或启动摄像头或麦克风。

然后,根据用户ID创建新的流视频元素,并通过replaceStream()方法将新流替换到新流中。通过获取当前调用对象存储,先前存储的当前流数据将在replaceStream()方法中被新流数据替换。

9)在 roomConnection 处我们需要创建一个按钮来切换视频和屏幕共享。

前端 - ./RoomConnection.js

    const [mediaType, setMediaType] = useState(false);    
    const toggleScreenShare = (displayStream ) => {
        const { reInitializeStream, toggleVideoTrack } = socketInstance.current;
        displayStream === 'displayMedia' && toggleVideoTrack({
            video: false, audio: true
        });
        reInitializeStream(false, true, displayStream).then(() => {
            setMediaType(!mediaType)
        });
    }
    return (
        <React.Fragment>
            <div id="room-container"></div>
            <button onClick={handleDisconnect}>Disconnect</button>
            <button 
                onClick={() => reInitializeStream(mediaType ? 
                'userMedia' : 'displayMedia')}
            >
            {mediaType ? 'screen sharing' : 'stop sharing'}</button>
        </React.Fragment>
    )
Enter fullscreen mode Exit fullscreen mode

这就是您要创建具有视频聊天和屏幕共享功能的应用程序的全部内容。

祝你好运!!!
这是我的工作演示 - vichah
查看我的博客 - https://arjhun777.blogspot.com/
Github 链接
前端 - https://github.com/Arjhun777/VChat-FrontEnd
后端 - https://github.com/Arjhun777/VChat-BackEnd

文章来源:https://dev.to/arjhun777/video-chatting-and-screen-sharing-with-react-node-webrtc-peerjs-18fg
PREV
NVIDIA 刚刚推出 AI 免费课程🎓📚
NEXT
新手开发者?正在找工作?避免这些 GitHub 错误!GitHub 是开发者的工作室