使用JS+socket.io+WebRTC+nodejs+express搭建一個簡易版遠程視頻聊天


目錄

WebRTC

代碼原理及流程

前端

先附上HTML和CSS

完整的socket.js

完整的userList.js(創建用戶在線列表,添加邀請事件,初始化聊天室)

遇到的問題

優化后完整的video.js

服務端

完整的server.js

實現效果

注意


WebRTC

網頁即時通信,Web Real-Time Communication 的縮寫,它支持peer-to-peer(瀏覽器與瀏覽器之間)進行視頻,音頻傳輸,並保證傳輸質量,將其發送至本地audio標簽,video標簽或發送到另一個瀏覽器中,本文使用到navigator.mediaDevicesRTCPeerConnection對象配合socket+node構建遠程實時視頻聊天功能,文章有一個不足之處,后面會講到。

相關文檔:
MediaDevices
WebRTC API

參考文章:
https://rtcdeveloper.com/t/topic/13777

代碼原理及流程

前端

要實現點對點就需要用到socket長鏈接從服務端進行尋呼
這是我以前的一篇關於socket簡單使用的小案例:https://blog.csdn.net/time_____/article/details/86748679
首先引入socket.io,這里我將前端js分成三部分,分別是socket.js(socket相關操作),userList.js(頁面操作),video.js(視頻聊天)

先附上HTML和CSS

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <link rel="stylesheet" href="./style/main.css">
    <script src="./js/socket.io.js"></script>
    <script src="./js/socket.js"></script>
    <script src="./js/userList.js"></script>
    <script src="./js/video.js"></script>
</head>

<body>
    <div id="login" hidden class="loginBox">
        <input id="userName" autocomplete="false" class="userName" type="text" placeholder="請輸入英文用戶名">
        <button id="submit">提交</button>
    </div>
    <div id="chatBox" class="chatBox" hidden>
        <h1 id="myName" class="myName"></h1>
        <ul id="userList" class="userList"></ul>
    </div>
    <div id="videoChat" hidden class="videoChat">
        <button id="back" hidden>結束</button>
        <video id="myVideo" src="" class="myVideo"></video>
        <video id="otherVideo" src="" class="otherVideo"></video>
    </div>
    <script>
        checkToken()

        function checkToken() { //判斷用戶是否已有用戶名
            if (localStorage.token) {
                login.hidden = true
                chatBox.hidden = false
                initSocket(localStorage.token) //初始化socket連接
            } else {
                login.hidden = false
                chatBox.hidden = true
                submit.addEventListener('click', function (e) {
                    initSocket(userName.value) //初始化socket連接
                })
            }
        }
    </script>
</body>

</html>
* {
    margin: 0;
    padding: 0;
}

.loginBox {
    width: 300px;
    height: 200px;
    margin: 50px auto 0;
}

.userName,
.loginBox button {
    width: 300px;
    height: 60px;
    border-radius: 10px;
    outline: none;
    font-size: 26px;
}

.userName {
    border: 1px solid lightcoral;
    text-align: center;
}

.loginBox button {
    margin-top: 30px;
    display: block;
}

input::placeholder {
    font-size: 26px;
    text-align: center;
}

.chatBox {
    width: 200px;
    margin: 50px auto 0;
    position: relative;
}

.myName {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 50px;
    font-size: 40px;
    text-align: center;
    line-height: 50px;
    background: lightcoral;
}

.userList {
    height: 500px;
    width: 100%;
    padding-top: 50px;
    overflow-y: scroll;
    list-style: none;
}

.userList>li {
    background: lightblue;
    height: 50px;
    font-size: 20px;
    line-height: 50px;
    text-align: center;
}

.videoChat {
    background: lightgreen;
    width: 500px;
    height: 400px;
    margin: 50px auto 0;
}

.videoChat button {
    width: 500px;
    height: 60px;
    border-radius: 10px;
    outline: none;
    float: left;
    font-size: 26px;
}

.myVideo,.otherVideo{
    width: 250px;
    height: 250px;
    float: left;
    overflow: hidden;
}

大致效果

socket.js
首先建立socket連接,添加連接和斷開的事件

let socket //供其他頁面調用

function initSocket(token) {//獲取到用戶輸入的id並傳到服務端
    socket = io('http://127.0.0.1:1024?token=' + token, {
        autoConnect: false
    });
    socket.open();
    socket.on('open', socketOpen); //連接登錄
    socket.on('disconnect', socketClose); //連接斷開
}

function socketClose(reason) { //主動或被動關閉socket
    console.log(reason)
    localStorage.removeItem("token")
}

function socketOpen(data) { //socket開啟
    if (!data.result) { //當服務端找到相同id時跳出連接
        console.log(data.msg)
        return;
    }
createChatList(data) //創建用戶列表
    localStorage.setItem('token', data.token)
    login.hidden = true
    chatBox.hidden = false
    videoChat.hidden = true
    myName.textContent = localStorage.token
}

之后在socket中添加幾個事件監聽

    socket.on('dataChange', createChatList); //新增人員
    socket.on('inviteVideoHandler', inviteVideoHandler); //被邀請視頻
    socket.on('askVideoHandler', askVideoHandler); //視頻邀請結果
    socket.on('ice', getIce); //從服務端接收ice
    socket.on('offer', getOffer); //從服務端接收offer
    socket.on('answer', getAnswer); //從服務端接收answer
    socket.on('break', stopVideoStream) //掛斷視頻通話

若用戶接到對方邀請時,彈出確認框,並將結果返回給對方

function inviteVideoHandler(data) { //當用戶被邀請時執行
    let allow = 0
    if (isCalling) {
        allow = -1 //正在通話
    } else {
        let res = confirm(data.msg);
        if (res) {
            allow = 1
            startVideoChat(data.token) //用戶點擊同意后開始初始化視頻聊天
            localStorage.setItem('roomNo', data.roomNo) //將房間號保存
        }
    }
    socket.emit('askVideo', {
        myId: localStorage.token,
        otherId: data.token,
        type: 'askVideo',
        allow
    });
}

當收到返回邀請結果時,后端創建視頻聊天房間后,開始初始化聊天室

function askVideoHandler(data) { //獲取被邀請用戶的回復
    console.log(data.msg)
    if (data.allow == -1) return //通話中
    if (data.allow) {
        localStorage.setItem('roomNo', data.roomNo) //將房間號保存
        startVideoChat(data.token)
    }
}

當用戶掛斷時

function breakVideoConnect(e) {
    console.log(localStorage.getItem('roomNo'))
    socket.emit('_break', {
        roomNo: localStorage.getItem('roomNo')
    });
}

 

  • 完整的socket.js
     

    let socket //供其他頁面調用
    
    function initSocket(token) {//獲取到用戶輸入的id並傳到服務端
        socket = io('http://127.0.0.1:1024?token=' + token, {
            autoConnect: false
        });
        socket.open();
        socket.on('open', socketOpen); //連接登錄
        socket.on('disconnect', socketClose); //連接斷開
        socket.on('dataChange', createChatList); //新增人員
        socket.on('inviteVideoHandler', inviteVideoHandler); //被邀請視頻
        socket.on('askVideoHandler', askVideoHandler); //視頻邀請結果
        socket.on('ice', getIce); //從服務端接收ice
        socket.on('offer', getOffer); //從服務端接收offer
        socket.on('answer', getAnswer); //從服務端接收answer
        socket.on('break', stopVideoStream) //掛斷視頻通話
    }
    
    function socketClose(reason) { //主動或被動關閉socket
        console.log(reason)
        localStorage.removeItem("token")
    }
    
    function socketOpen(data) { //socket開啟
        if (!data.result) { //當服務端找到相同id時跳出連接
            console.log(data.msg)
            return;
        }
        createChatList(data) //創建用戶列表
        localStorage.setItem('token', data.token)
        login.hidden = true
        chatBox.hidden = false
        videoChat.hidden = true
        myName.textContent = localStorage.token
    }
    
    function inviteVideoHandler(data) { //當用戶被邀請時執行
        let allow = 0
        if (isCalling) {
            allow = -1 //正在通話
        } else {
            let res = confirm(data.msg);
            if (res) {
                allow = 1
                startVideoChat(data.token) //用戶點擊同意后開始初始化視頻聊天
                localStorage.setItem('roomNo', data.roomNo) //將房間號保存
            }
        }
        socket.emit('askVideo', {
            myId: localStorage.token,
            otherId: data.token,
            type: 'askVideo',
            allow
        });
    }
    
    function askVideoHandler(data) { //獲取被邀請用戶的回復
        console.log(data.msg)
        if (data.allow == -1) return //通話中
        if (data.allow) {
            localStorage.setItem('roomNo', data.roomNo) //將房間號保存
            startVideoChat(data.token)
        }
    }
    
    function breakVideoConnect(e) {
        console.log(localStorage.getItem('roomNo'))
        socket.emit('_break', {
            roomNo: localStorage.getItem('roomNo')
        });
    }

     

  • 完整的userList.js(創建用戶在線列表,添加邀請事件,初始化聊天室)

function createChatList(data) { //新建用戶列表
    console.log(data.msg)
    let userData = data.userIds
    let userList = document.querySelector('#userList')
    if (userList) {
        userList.remove()
        userList = null
    }
    userList = createEle('ul', {}, {
        id: 'userList',
        className: 'userList'
    })
    chatBox.appendChild(userList)
    for (let key in userData) {
        if (userData[key] != localStorage.token) {
            var li = createEle('li', {}, {
                textContent: userData[key]
            })
            li.addEventListener('click', videoStart)
            userList.appendChild(li)
        }
    }
}

function createEle(ele, style, attribute) { //新增標簽,設置屬性及樣式
    let element = document.createElement(ele)
    if (style) {
        for (let key in style) {
            element.style[key] = style[key];
        }
    }
    if (attribute) {
        for (let key in attribute) {
            element[key] = attribute[key];
        }
    }
    return element
}

function videoStart(e) { //用戶點擊列表某個用戶時發送邀請至服務端
    socket.emit('inviteVideo', {
        myId: localStorage.token,
        otherId: this.textContent,
        type: 'inviteVideo'
    });
}

function startVideoChat(otherId) { //初始化視頻聊天
    videoChat.hidden = false
    login.hidden = true
    chatBox.hidden = true
    localStorage.setItem('otherId', otherId) //將對方的id保存
    startVideoStream()
}

video.js

初始化媒體對象,並將Stream存到全局,這里由於navigator.mediaDevices.getUserMedia是異步方法,需要同步執行,先獲取stream,然后進行后續操作

async function createMedia() { //同步創建本地媒體流
    if (!stream) {
        stream = await navigator.mediaDevices.getUserMedia({
            audio: true,
            video: true
        })
    }
    console.log(stream)
    let video = document.querySelector('#myVideo');
    video.srcObject = stream; //將媒體流輸出到本地video以顯示自己
    video.onloadedmetadata = function (e) {
        video.play();
    };
    createPeerConnection()
}

創建stream后,初始化RTCPeerConnection,用於建立視頻連接,同樣需要同步獲取,並且在peer獲取之后發送offer給對方


async function createPeerConnection() { //同步初始化描述文件並添加事件
    if (!peer) {
        peer = new RTCPeerConnection()
    }
    await stream.getTracks().forEach(async track => {
        await peer.addTrack(track, stream); //將本地流附屬至peer中
    });
    // await peer.addStream(stream); //舊方法(將本地流附屬至peer中)
    peer.addEventListener('addstream', setVideo) //當peer收到其他流時顯示另一個video以顯示對方
    peer.addEventListener('icecandidate', sendIce) //獲取到candidate時,將其發送至服務端,傳至對方
    peer.addEventListener('negotiationneeded', sendOffer) //雙方約定的協商被完成時才觸發該方法
}

當收到對方發送過來的stream時,即觸發addstream事件時,通過setVideo將對方的視頻流放到本地video中

function setVideo(data) { //播放對方的視頻流
    console.log(data.stream)
    let back = document.getElementById('back')
    back.hidden = false //顯示掛斷按鈕
    back.addEventListener('click', breakVideoConnect) //掛斷事件
    isCalling = true //正在通話
    let video = document.querySelector('#otherVideo');
    video.srcObject = data.stream;
    video.onloadedmetadata = function (e) {
        video.play();
    };
}

 創建offer,保存本地offer,並發送offer給對方

async function sendOffer() { //同步發送offer到服務端,發送給對方
    let offer = await peer.createOffer();
    await peer.setLocalDescription(offer); //peer本地附屬offer
    socket.emit('_offer', {
        streamData: offer
    });
}

收到對方的offer后,保存遠程offer,但是這里有一個小問題,如果peer還沒有創建好,也就是如果對方先創建,就會馬上發offer過來,這時我們這邊的peer可能還沒有創建成功,如果直接調用setRemoteDescription的話會報錯,所以可以用try  catch來調用,或使用if(!peer) return的方式運行

async function getOffer(data) { //接收到offer后,返回answer給對方
    await peer.setRemoteDescription(data.streamData); //peer遠程附屬offer
    sendAnswer()
}


//優化后
async function getOffer(data) { //接收到offer后,返回answer給對方
    if (!peer) return //等待對方響應,也可以用try catch
    await peer.setRemoteDescription(data.streamData); //peer遠程附屬offer
    sendAnswer()
}

創建answer,保存本地answer,發送answer給對方

async function sendAnswer() {
    let answer = await peer.createAnswer();
    await peer.setLocalDescription(answer); //peer附屬本地answer
    socket.emit('_answer', {
        streamData: answer
    });
}

 接收到answer時,保存本地answer

async function getAnswer(data) { //接收到answer后,peer遠程附屬answer
    await peer.setRemoteDescription(data.streamData);
}

peer觸發icecandidate事件時,即本地觸發過setLocalDescription時,也就是將本地offer和本地answer保存時,觸發方法

function sendIce(e) { //setLocalDescription觸發時,發送ICE給對方
    if (e.candidate) {
        socket.emit('_ice', {
            streamData: e.candidate
        });
    }
}

 接收對方的ICE,但是這里有一個和上面一樣的小問題如果在ICE事件中,peer還沒有創建好,也就是如果對方先創建,就會馬上發offer過來,這時我們這邊的peer可能還沒有創建成功,如果直接調用addIceCandidate的話會報錯,所以可以用try  catch來調用,或使用if(!peer) return的方式運行

async function getIce(data) { //獲取對方的ICE
    var candidate = new RTCIceCandidate(data.streamData)
    await peer.addIceCandidate(candidate)
}

//優化后

async function getIce(data) { //獲取對方的ICE
    if (!peer) return //等待對方響應,也可以用try catch
    var candidate = new RTCIceCandidate(data.streamData)
    await peer.addIceCandidate(candidate)
}

遇到的問題

最后是掛斷的方法,這里有個小問題,當掛斷時,原來的stream無法刪除,導致攝像頭雖然沒有調用,但是導航欄仍然會有攝像頭圖標(沒有真正關閉),下一次打開時會傳輸前面的流(疊加),網上沒有解決方式,如果有知道的同學,希望能補充優化,感謝

function stopVideoStream(data) { //停止傳輸視頻流
    console.log(data.msg)
    stream.getTracks().forEach(async function (track) { //這里得到視頻或音頻對象
        await track.stop();
        await stream.removeTrack(track)
        stream = null
    })
    peer.close();
    peer = null;
    isCalling = false
    videoChat.hidden = true
    login.hidden = true
    chatBox.hidden = false
}
  • 優化后完整的video.js
     

    var stream, peer, isCalling = false //初始化要發送的流,和描述文件,通話狀態
    function startVideoStream(e) { //開始傳輸視頻流
        createMedia()
    }
    
    function stopVideoStream(data) { //停止傳輸視頻流
        console.log(data.msg)
        stream.getTracks().forEach(async function (track) { //這里得到視頻或音頻對象
            await track.stop();
            await stream.removeTrack(track)
            stream = null
        })
        peer.close();
        peer = null;
        isCalling = false
        videoChat.hidden = true
        login.hidden = true
        chatBox.hidden = false
    }
    
    async function createMedia() { //同步創建本地媒體流
        if (!stream) {
            stream = await navigator.mediaDevices.getUserMedia({
                audio: true,
                video: true
            })
        }
        console.log(stream)
        let video = document.querySelector('#myVideo');
        video.srcObject = stream; //將媒體流輸出到本地video以顯示自己
        video.onloadedmetadata = function (e) {
            video.play();
        };
        createPeerConnection()
    }
    
    async function createPeerConnection() { //同步初始化描述文件並添加事件
        if (!peer) {
            peer = new RTCPeerConnection()
        }
        await stream.getTracks().forEach(async track => {
            await peer.addTrack(track, stream); //將本地流附屬至peer中
        });
        // await peer.addStream(stream); //舊方法(將本地流附屬至peer中)
        peer.addEventListener('addstream', setVideo) //當peer收到其他流時顯示另一個video以顯示對方
        peer.addEventListener('icecandidate', sendIce) //獲取到candidate時,將其發送至服務端,傳至對方
        peer.addEventListener('negotiationneeded', sendOffer) //雙方約定的協商被完成時才觸發該方法
    }
    
    function setVideo(data) { //播放對方的視頻流
        console.log(data.stream)
        let back = document.getElementById('back')
        back.hidden = false //顯示掛斷按鈕
        back.addEventListener('click', breakVideoConnect) //掛斷事件
        isCalling = true //正在通話
        let video = document.querySelector('#otherVideo');
        video.srcObject = data.stream;
        video.onloadedmetadata = function (e) {
            video.play();
        };
    }
    
    async function sendOffer() { //同步發送offer到服務端,發送給對方
        let offer = await peer.createOffer();
        await peer.setLocalDescription(offer); //peer本地附屬offer
        socket.emit('_offer', {
            streamData: offer
        });
    }
    
    async function getOffer(data) { //接收到offer后,返回answer給對方
        if (!peer) return //等待對方響應,也可以用try catch
        await peer.setRemoteDescription(data.streamData); //peer遠程附屬offer
        sendAnswer()
    }
    
    async function sendAnswer() {
        let answer = await peer.createAnswer();
        await peer.setLocalDescription(answer); //peer附屬本地answer
        socket.emit('_answer', {
            streamData: answer
        });
    }
    
    async function getAnswer(data) { //接收到answer后,peer遠程附屬answer
        await peer.setRemoteDescription(data.streamData);
    }
    
    function sendIce(e) { //setLocalDescription觸發時,發送ICE給對方
        if (!e || !e.candidate) return
        socket.emit('_ice', {
            streamData: e.candidate
        });
    }
    
    async function getIce(data) { //獲取對方的ICE
        if (!peer) return //等待對方響應,也可以用try catch
        var candidate = new RTCIceCandidate(data.streamData)
        await peer.addIceCandidate(candidate)
    }

     

服務端

后端部分同樣使用socketio進行通信
首先在npm初始化后下載express,socket.io
 

npm i express --save-dev
npm i socket.io --save-dev

之后引入至server.js中

const express = require('express')
const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);

並監聽1024端口

server.listen(1024, function () {
    console.log('Socket Open')
});

配置socket
給socket添加一些事件

io.on('connect', socket => {
    let {
        token
    } = socket.handshake.query
    socket.on('disconnect', (exit) => { //socket斷開
        delFormList(token) //清除用戶
        broadCast(socket, token, 'leave') //廣播給其他用戶
    })
});

這樣,我們最簡單的一個socket就搭好了

完整的server.js

const express = require('express')
const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);
let userList = {} //用戶列表,所有連接的用戶
let userIds = {} //用戶id列表,顯示到前端
let roomList = {} //房間列表,視頻聊天
io.on('connect', socket => {
    let {
        token
    } = socket.handshake.query
    socket.on('disconnect', (exit) => { //socket斷開
        delFormList(token) //清除用戶
        broadCast(socket, token, 'leave') //廣播給其他用戶
    })
    socket.on('inviteVideo', inviteVideoHandler) //邀請用戶
    socket.on('askVideo', inviteVideoHandler); //回應用戶是否邀請成功
    if (userList[token]) { //找到相同用戶名就跳出函數
        socket.emit('open', {
            result: 0,
            msg: token + '已存在'
        });
        socket.disconnect()
        return;
    }
    addToList(token, socket) //用戶連接時,添加到userList
    broadCast(socket, token, 'enter') //廣告其他用戶,有人加入
});

function addToList(key, item) { //添加到userList
    item.emit('open', {
        result: 1,
        msg: '你已加入聊天',
        userIds,
        token: key
    });
    userList[key] = item
    userIds[key] = key
}

function delFormList(key) { //斷開時,刪除用戶
    delete userList[key];
    delete userIds[key]
}

function broadCast(target, token, type) { //廣播功能
    let msg = '加入聊天'
    if (type !== 'enter') {
        msg = '離開聊天'
    }
    target.broadcast.emit('dataChange', {
        result: 1,
        msg: token + msg,
        userIds
    });
}

function inviteVideoHandler(data) { //邀請方法
    let {
        myId,
        otherId,
        type,
        allow
    } = data, msg = '邀請你進入聊天室', event = 'inviteVideoHandler', roomNo = otherId //默認房間號為邀請方id
    if (type == 'askVideo') {
        event = 'askVideoHandler'
        if (allow == 1) {
            addRoom(myId, otherId)
            roomNo = myId //保存房間號
            msg = '接受了你的邀請'
        } else if (allow == -1) {
            msg = '正在通話'
        } else {
            msg = '拒絕了你的邀請'
        }
    }
    userList[otherId].emit(event, {
        msg: myId + msg,
        token: myId,
        allow,
        roomNo
    });
}

async function addRoom(myId, otherId) { //用戶同意后添加到視頻聊天室,只做1對1聊天功能
    roomList[myId] = [userList[myId], userList[otherId]]
    startVideoChat(roomList[myId])
}

function startVideoChat(roomItem) { //視頻聊天初始化
    for (let i = 0; i < roomItem.length; i++) {
        roomItem[i].room = roomItem
        roomItem[i].id = i
        roomItem[i].on('_ice', _iceHandler)
        roomItem[i].on('_offer', _offerHandler)
        roomItem[i].on('_answer', _answerHandler)
        roomItem[i].on('_break', _breakRoom)

    }
}

function _iceHandler(data) { //用戶發送ice到服務端,服務端轉發給另一個用戶
    let id = this.id == 0 ? 1 : 0 //判斷用戶二者其一
    this.room[id].emit('ice', data);
}

function _offerHandler(data) { //用戶發送offer到服務端,服務端轉發給另一個用戶
    let id = this.id == 0 ? 1 : 0
    this.room[id].emit('offer', data);
}

function _answerHandler(data) { //用戶發送answer到服務端,服務端轉發給另一個用戶
    let id = this.id == 0 ? 1 : 0
    this.room[id].emit('answer', data);
}

function _breakRoom(data) { //掛斷聊天
    for (let i = 0; i < roomList[data.roomNo].length || 0; i++) {
        roomList[data.roomNo][i].emit('break', {
            msg: '聊天掛斷'
        });
    }
}
server.listen(1024, function () {
    console.log('Socket Open')
});

實現效果

分別在我方發送stream之前打印stream,收到對方stream后打印stream,我們會發現,雙方的stream互換了位置,也就是說整個媒體對象進行了交換

和同事在兩台電腦上測試效果 

 

注意:

前端項目必須運行在本地或者https服務下,因為navigator.mediaDevices.getUserMedia需要運行在安全模式下,用戶第一次使用需要授權攝像頭或音頻權限,所以雙方電腦需要有相關功能

看到這里,希望留下你寶貴的建議

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM