JavaScript - 来个多人ㄉ视讯聊天室

大家好~上次文章介绍了录音录影的功能,这次要来弄个多人视讯聊天室,竟然我们已经知道怎麽取得影音档案,那麽对於聊天室而言就只少了一个东西,那就是交换资料的手段,所以这篇就来介绍一下怎麽使用浏览器的 API 建立 P2P 的连线,并达到交换 stream 的功能~

API 介绍

首先我们先来介绍一下这次的重点 API,分别是 RTCPeerConnectionRTCDataChannel

RTCPeerConnection

RTCPeerConnection 是建立 P2P 连线的 API,使用时会使用 new 来建立实体,建立实体之後写入自己与对方的连线资讯,接着就会自动建立连线了

Method

基本上比较常用的就是下面几个,不过有几点小提醒

  • 送出方为 Offer,接收方为 Answer
  • 同步与非同步需要特别注意
  • 必须先将 stream 加入实体後再建立连线
  • 连接埠可以在建立连线後设定
// 建立实体
const peer = new RTCPeerConnection(config)
// 将 stream 加入实体
peer.addTrack(track, stream)
// 产生 Offer(返回 Promise)
peer.createOffer(offerOptions)
// 产生 Answer(返回 Promise)
peer.createAnswer()
// 设定本地端连线资讯(返回 Promise)
peer.setLocalDescription(offer)
// 设定远端连线资讯(返回 Promise)
peer.setRemoteDescription(answer)
// 设定连线的连接埠(返回 Promise)
peer.addIceCandidate(candidate)

Event

建立实体後我们可以监听各种事件,接着在触发事件时就可以做相应的处理,以下为常用事件

// 找到连接埠时触发,会有多个(TCP、UDP)
peer.addEventListener('icecandidate', e => {
  // do something...
})
// 连接埠状态变化时触发
peer.addEventListener('iceconnectionstatechange', e => {
  // do something...
})
// 取得对方影音时触发
peer.addEventListener('track', e => {
  // do something...
})
// 建立 channel 後,在双方连线时触发
peer.addEventListener('datachannel', e => {
  // do something...
})
// 需要建立连线时触发(初始化、stream 改变)
localPeer.addEventListener('negotiationneeded', e => {
  // do something...
})

RTCDataChannel

RTCDataChannel,是建立在 RTCPeerConnection 实体上,可以用来交换讯息或档案,档案目前建议使用 ArrayBuffer 来处理

Method

const peer = new RTCPeerConnection(config)
// 建立名为 channel 的 RTCDataChannel
channel = peer.createDataChannel('channel')

// 发送讯息或档案,remoteChannel 为 datachannel 事件返回的 channel
remoteChannel.send(data)

Event

// 接收到讯息或档案後触发
channel.addEventListener('message', e => {
  // do something...
})

整理一下重点,这边建立影音双向的连线必须要有三个条件

  1. 在建立连线之前先加入 stream
  2. 双方都设定好自己的 Offer 与对方的 Answer
  3. 设定好连线的 candidate(连接埠)

以上就是交换 stream 的方法了,那麽在开始实作之前先来看一下流程吧!

流程介绍

实作会分成後端服务器与前端影音的部份,当前端建立连线之前我们不知道要跟谁连线,所以需要後端来帮我们传送彼此的连线资讯,大概的流程如下:

  1. 用户 A 进入聊天室
  2. 服务器将用户 A 记录到用户清单,并将其他用户清单传送给用户 A
  3. 用户 A 对自己以外的用户发起连线请求,并夹带自己的连线资讯
  4. 其他用户收到後同意请求并回传自己的连线资讯
  5. 用户 A 收到其他用户的连线资讯,双方成功建立 P2P 连线
  6. 有人进入聊天室时重复步骤 1

这边有两种做法,两种都可以达成目的

  1. 由加入者对其他用户发起连线
  2. 由聊天室内的用户对加入者发起连线

我们这边采用方法 2,而服务器为了及时知道目前有谁加入所以会使用 WebSocket 来实作

开始实作

首先我们先来定义一下前後端交换资料时的规格

  • event:事件的类别
    • init:初始化
    • request:请求连线
    • response:回应请求
    • candidate:传送连接埠
    • close:关闭连线
  • id:用户 ID(init
  • userList:所有聊天室内的用户清单(init
  • sender:发送者(requestresponsecandidateclose
  • taker:接收者(requestresponsecandidateclose
  • connectionOfferAnswerrequestresponse
  • candidate:连接埠资讯(candidate

後端实作

後端我们使用 express 加上 express-ws 来实作,首先安装套件

$ npm init -y
$ npm install express
$ npm install express-ws

安装好了之後我们在根目录建立一个 index.js 撰写 node 程序,注意一下 WebSocket 传送的资料都是字串的格式,所以我们会使用 JSON.stringifyJSON.parse 来转换

// index.js

// 使用 express 与 express-ws
const express = require('express')
const app = express()
const expressWs = require('express-ws')(app)
// 使用根目录档案作为页面
app.use(express.static(__dirname))

// 所有聊天室内的 WebSocket 实例
let websocketList = []
// 开启 WebSocket 连线网址为 ws://localhost:3000/connection
app.ws('/connection', ws => {
  // 开启连线时触发
  // 使用 timestamp 当作 id
  const id = new Date().getTime()
  // 实例绑定该 id
  ws.id = id
  // 送出初始化事件
  ws.send(JSON.stringify({
    event: 'init',
	id,
	userList: websocketList.map(item => item.id)
  }))
  // 将 WebSocket 实例放入清单
  websocketList.push(ws)

  // 收到讯息时触发
  ws.on('message', msg => {
    const data = JSON.parse(msg)

    // 找到发送者的 WebSocket 实例
    const taker = websocketList.find(item => item.id === data.taker)
    // 请求连线
    if (data.event === 'request') {
      taker.send(JSON.stringify({
        event: 'request',
        sender: data.sender,
        connection: data.connection
      }))
    }
    // 回应请求
    if (data.event === 'response') {
      taker.send(JSON.stringify({
        event: 'response',
        sender: data.sender,
        connection: data.connection
      }))
    }
    // 传送连接埠资讯
    if (data.event === 'candidate') {
      taker.send(JSON.stringify({
        event: 'candidate',
        sender: data.sender,
        candidate: data.candidate
      }))
    }
  })
  
  // 关闭连线时触发
  ws.on('close', () => {
    // 将 WebSocket 实例从清单移除
    websocketList = websocketList.filter(item => item !== ws)
    // 通知其他人将该连线关闭
	websocketList.forEach(client => {
      client.send(JSON.stringify({
        event: 'close',
        sender: ws.id
      }))
    })
  })
})

app.listen(3000)

这样後端程序就完成了,基本上只是做一些资讯的传送而已

前端实作

首先简单弄个版~

※ 提醒一下,这篇使用 chrome 测试,不同浏览器的支援度与安全性设定会有些不同

<!-- index.html -->

<h1>聊天室</h1>
<button id="camera">视讯镜头</button>
<button id="screen">分享萤幕</button>
<button id="close">关闭分享</button>
<input id="textInput" type="text">
<button id="submit">送出讯息</button>
<input id="fileInput" type="file">

<div class="wrap">
  <div>
    <video id="video" autoplay></video>
  </div>
</div>
video {
  height: 100%;
  width: 100%;
}
.wrap {
  display: flex;
  flex-wrap: wrap;
}
.wrap > div {
  width: 25%;
  height: 250px;
  background-color: #000;
  margin: 0.5rem 0.5rem 0;
}

版面会长这样,不是很美观将就一下啦QQ
版面

接着我们先看一下变数与大概的结构

const video = document.querySelector('#video')
const cameraBtn = document.querySelector('#camera')
const screenBtn = document.querySelector('#screen')
const closeBtn = document.querySelector('#close')
const textInput = document.querySelector('#textInput')
const submitBtn = document.querySelector('#submit')
const fileInput = document.querySelector('#fileInput')

// stream 档案
let cameraStream
let screenStream

// mediaDevices 的设定
const constraints = { audio: true, video: true }
// offer 的设定
const offerOptions = { offerToReceiveAudio: true,  offerToReceiveVideo: true }

// 自己的 ID
let myId
// 所有人员的清单
let userList = []

cameraBtn.addEventListener('click', () => {
  // 取得视讯镜头的 stream
})
screenBtn.addEventListener('click', () => {
  // 取得萤幕分享的 stream
})
closeBtn.addEventListener('click', () => {
  // 关闭视讯镜头与萤幕分享的 stream
})
submitBtn.addEventListener('click', () => {
  // 送出文字讯息
})
fileInput.addEventListener('change', () => {
  // 送出档案
})

function init() {
  // 建立 WebSocket 连线
  const ws = new WebSocket('ws://localhost:3000/connection')

  // 收到讯息触发该事件
  ws.addEventListener('message', async e => {
    // 转换字串讯息为物件
    const data = JSON.parse(e.data)
    
    // 找到送出讯息的人(init 以外使用)
    const sender = userList.find(user => user.id === data.sender)
    
    // 第一次开启 WebSocket 连线时触发
    if (data.event === 'init') {
      // do something...
    }
    
    // 收到别人发出的请求时触发
    if (data.event === 'request') {
      // do something...
    }
    
    // 收到回覆时触发
    if (data.event === 'response') {
      // do something...
    }
    
    // 有人传送连接埠时触发
    if (data.event === 'candidate') {
      // do something...
    }
    
    // 有人离开时触发
    if (data.event === 'close') {
      // do something...
    }
  })
}

init()

接着我们先来填入各个事件该做的事情吧

cameraBtn click

cameraBtn.addEventListener('click', () => {
  if (cameraStream) return
  // 取得视讯镜头的 stream
  navigator.mediaDevices.getUserMedia(constraints).then(stream => {
    // 将本来萤幕分享的 stream 清除
    if (screenStream) {
      screenStream.getTracks().forEach(track => {
        track.stop()
      })
      screenStream = null
    }
    // 设定视讯镜头的 stream 到画面
    cameraStream = stream
    video.srcObject = stream

    userList.forEach(user => {
      if (!user.peer) return
      // peer 移除之前的 stream
      user.peer.getSenders().forEach(sender => {
        user.peer.removeTrack(sender)
      })
      // peer 新增新的 stream
      stream.getTracks().forEach(track => {
        user.peer.addTrack(track, stream)
      })
    })
  })
})

screenBtn click

screenBtn.addEventListener('click', () => {
  if (screenStream) return
  // 取得萤幕分享 stream
  navigator.mediaDevices.getDisplayMedia(constraints).then(stream => {
    if (cameraStream) {
      // 将本来视讯镜头的 stream 清除
      cameraStream.getTracks().forEach(track => {
        track.stop()
      })
      cameraStream = null
    }
    // 设定萤幕分享的 stream 到画面
    screenStream = stream
    video.srcObject = stream

    userList.forEach(user => {
      if (!user.peer) return
      // peer 移除之前的 stream
      user.peer.getSenders().forEach(sender => {
        user.peer.removeTrack(sender)
      })
      // peer 新增新的 stream
      stream.getTracks().forEach(track => {
        user.peer.addTrack(track, stream)
      })
    })
  })
})

closeBtn click

closeBtn.addEventListener('click', () => {
  if (screenStream) {
    // 将萤幕分享的 stream 清除
    screenStream.getTracks().forEach(track => {
      track.stop()
    })
    screenStream = null
  }
  if (cameraStream) {
    // 将视讯镜头的 stream 清除
    cameraStream.getTracks().forEach(track => {
      track.stop()
    })
    cameraStream = null
  }

  // 所有的 peer 移除之前的 stream
  userList.forEach(user => {
    user.peer.getSenders().forEach(sender => {
      user.peer.removeTrack(sender)
    })
  })
})

submitBtn click

submitBtn.addEventListener('click', () => {
  const value = textInput.value
  if (!value) return
  // 所有的 peer 送出文字讯息
  userList.forEach(user => {
    if (!user.channel) return
    user.channel.send(value)
  })
})

fileInput change

fileInput.addEventListener('change', e => {
  const file = e.target.files[0]
  if (!file) return
  // 这边设定仅接受 jpeg 格式
  if (file.type !== 'image/jpeg') return
  // 将档案转换成 ArrayBuffer
  const reader = new FileReader()
  reader.readAsArrayBuffer(file)
  reader.onload = e => {
    // 所有的 peer 送出 ArrayBuffer
    userList.forEach(user => {
      if (!user.channel) return
      user.channel.send(e.target.result)
    })
  }
})

WebSocket event - init

// 第一次开启 WebSocket 连线时触发
if (data.event === 'init') {
  // 设定自己的 ID
  myId = data.id
  // 设定所有人员的清单
  userList = data.userList.map(id => ({ id, peer: null, channel: null }))
  // 对所有人员发起连线
  userList.forEach(async user => {
    user.peer = new RTCPeerConnection()
    user.peer.addEventListener('icecandidate', e => {
      // 传送连接埠资讯
      ws.send(JSON.stringify({
        event: 'candidate',
        sender: myId,
        taker: user.id,
        candidate: e.candidate 
      }))
    })
    user.peer.addEventListener('connectionstatechange', e => {
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      if (currentVideo) return
      // 初始化画面 video
      const div = document.createElement('div')
      div.id = `video_${user.id}`
      const video = document.createElement('video')
      video.autoplay = true
      div.appendChild(video)
      const wrap = document.querySelector('.wrap')
      wrap.appendChild(div)
    })
    user.peer.addEventListener('track', e => {
      // 将 stream 显示於画面
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = e.streams[0]
    })
    user.peer.addEventListener('removestream', e => {
      // 将 stream 从画面移除
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = null
    })
    user.peer.addEventListener('datachannel', e => {
      // 将对方的 channel 写入物件
      user.channel = e.channel
    })
    user.peer.addEventListener('negotiationneeded', async e => {
      // 连接尚未建立时不动作
      if (user.peer.connectionState !== 'connected') return
      // 重新发出请求并建立连线
      const offer = await user.peer.createOffer(offerOptions)
      await user.peer.setLocalDescription(offer)
      ws.send(JSON.stringify({
        event: 'request',
        sender: myId,
        taker: user.id,
        connection: offer
      }))
    })
    // 建立 DataChannel
    channel = user.peer.createDataChannel('channel')
    channel.addEventListener('message', e => {
      if (typeof e.data === 'object') {
        // 收到档案时询问後下载该档案
        const message = `是否下载 ${user.id} 提供的档案?`
        const result = confirm(message)
        if (!result) return
        const blob = new Blob([e.data], { type: 'image/jpeg' })
        const downloadLink = document.createElement('a')
        downloadLink.href = URL.createObjectURL(blob)
        downloadLink.download = 'download'
        downloadLink.click()
        URL.revokeObjectURL(downloadLink.href)
      } else {
        // 收到文字时使用 alert 印出
        const message = `${user.id}: ${e.data}`
        alert(message)
      }
    })

    if (cameraStream) {
      // 将视讯镜头的 stream 加入 peer
      cameraStream.getTracks().forEach(track => {
        user.peer.addTrack(track, cameraStream)
      })
    }
    if (screenStream) {
      // 将萤幕分享的 stream 加入 peer
      screenStream.getTracks().forEach(track => {
        user.peer.addTrack(track, screenStream)
      })
    }

    // 发出请求并建立连线
    const offer = await user.peer.createOffer(offerOptions)
    await user.peer.setLocalDescription(offer)
    ws.send(JSON.stringify({
      event: 'request',
      sender: myId,
      taker: user.id,
      connection: offer
    }))
  })
}

WebSocket event - request

// 收到别人发出的请求时触发
if (data.event === 'request') {
  if (!sender) {
    // 新成员加入
    // 建立该人员的资讯并放入清单
    const user = { id: data.sender, peer: null, channel: null }
    userList.push(user)
    user.peer = new RTCPeerConnection()
    user.peer.addEventListener('icecandidate', e => {
      // 传送连接埠资讯
      ws.send(JSON.stringify({
        event: 'candidate',
        sender: myId,
        taker: user.id,
        candidate: e.candidate 
      }))
    })
    user.peer.addEventListener('connectionstatechange', e => {
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      if (currentVideo) return
      // 初始化画面 video
      const div = document.createElement('div')
      div.id = `video_${user.id}`
      const video = document.createElement('video')
      video.autoplay = true
      div.appendChild(video)
      const wrap = document.querySelector('.wrap')
      wrap.appendChild(div)
    })
    user.peer.addEventListener('track', e => {
      // 将 stream 显示於画面
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = e.streams[0]
    })
    user.peer.addEventListener('removestream', e => {
      // 将 stream 从画面移除
      const currentVideo = document.querySelector(`#video_${user.id} > video`)
      currentVideo.srcObject = null
    })
    user.peer.addEventListener('datachannel', e => {
      // 将对方的 channel 写入物件
      user.channel = e.channel
    })
    user.peer.addEventListener('negotiationneeded', async e => {
      // 连接尚未建立时不动作
      if (user.peer.connectionState !== 'connected') return
      // 重新发出请求并建立连线
      const offer = await user.peer.createOffer(offerOptions)
      await user.peer.setLocalDescription(offer)
      ws.send(JSON.stringify({
        event: 'request',
        sender: myId,
        taker: user.id,
        connection: offer
      }))
    })
    // 建立 DataChannel
    channel = user.peer.createDataChannel('channel')
    channel.addEventListener('message', e => {
      if (typeof e.data === 'object') {
        // 收到档案时询问後下载该档案
        const message = `是否下载 ${user.id} 提供的档案?`
        const result = confirm(message)
        if (!result) return
        const blob = new Blob([e.data], { type: 'image/jpeg' })
        const downloadLink = document.createElement('a')
        downloadLink.href = URL.createObjectURL(blob)
        downloadLink.download = 'download'
        downloadLink.click()
        URL.revokeObjectURL(downloadLink.href)
      } else {
        // 收到文字时使用 alert 印出
        const message = `${user.id}: ${e.data}`
        alert(message)
      }
    })

    if (cameraStream) {
      // 将视讯镜头的 stream 加入 peer
      cameraStream.getTracks().forEach(track => {
        user.peer.addTrack(track, cameraStream)
      })
    }
    if (screenStream) {
      // 将萤幕分享的 stream 加入 peer
      screenStream.getTracks().forEach(track => {
        user.peer.addTrack(track, screenStream)
      })
    }

    // 设定该 peer 的连线资讯并回覆自己的连线资讯
    await user.peer.setRemoteDescription(data.connection)
    const answer = await user.peer.createAnswer(offerOptions)
    await user.peer.setLocalDescription(answer)
    ws.send(JSON.stringify({
      event: 'response',
      sender: myId,
      taker: user.id,
      connection: answer
    }))
  } else {
    // 设定该 peer 的连线资讯并回覆自己的连线资讯
    await sender.peer.setRemoteDescription(data.connection)
    const answer = await sender.peer.createAnswer(offerOptions)
    await sender.peer.setLocalDescription(answer)
    ws.send(JSON.stringify({
      event: 'response',
      sender: myId,
      taker: sender.id,
      connection: answer
    }))
  }
}

WebSocket event - response

// 收到回覆时触发
if (data.event === 'response') {
  // 设定该 peer 的连线资讯
  sender.peer.setRemoteDescription(data.connection)
}

WebSocket event - candidate

// 有人传送连接埠时触发
if (data.event === 'candidate') {
  // 设定该 peer 的连接埠
  sender.peer.addIceCandidate(data.candidate)
}

WebSocket event - close

// 有人离开时触发
if (data.event === 'close') {
  // 清单移除离开者
  userList = userList.filter(user => user !== sender)
  // 关闭该连线
  sender.peer.close()
  // 移除离开者的画面
  const videoDiv = document.querySelector(`#video_${data.sender}`)
  if (videoDiv) videoDiv.remove()
}

WebSocket event - candidate

// 有人传送连接埠时触发
if (data.event === 'candidate') {
  // 设定该 peer 的连接埠
  sender.peer.addIceCandidate(data.candidate)
}

接着就可以正常运作啦~洒花
成品

结语

终於做完啦~聊天室是一个很贴近日常的应用,做完真的是成就感满满阿,学完之前的 mediaDevices 再来看聊天室是不是很简单呢(才怪),基本上最重要的就是搞清楚设定 offer 与 answer 的顺序,其他的就不算什麽了~


<<:  Day6 Class vs Function

>>:  DAY8-JAVA的类别(2)

2022新年挑战 - 7 days for Javascript(Day 1 - Developer Set Up)

因为在工作上, 基本上碰不到Javascript, 感觉再不复习一下, 就要忘光光了 (汗) 所以决...

EP04 - 开始使用 Terraform 配置 aws 基础设施

Terraform Terraform 是什麽 Terraform 是由 HashiCorp 建立的...

Day 7 - 使用 AES-CBC 机制对 Message 内文进行加密

图 7-1: 各栏位资料范例 本文的目标是将如 Message 栏位的内文使用 AES 加密机制将...

[Day12] JavaScript - 闭包 Closure

闭包(Closure)在MDN的解释为: 是函式以及该函式被宣告时所在的作用域环境(lexical ...

[Day08] Let's Build!

接续昨天提到的,我们今天将会实际跑一次指令,如果看到这里的你还在犹豫的话,别犹豫了,跟我一起开始吧!...