写在开头
哈喽,各位好吖!😀 又是美好的一天呢。
七月了,也就是2024年下半年已经开始了,时间流逝飞快呢。🏃🏃🏃
各位上半年是否已经有所收获了呢?😁 反正小编是没有😴,还是平平淡淡,按部就班,过着自己的小日子,如下图:
此时唯一的愿望就是开开心心,健健康康过好当下就好,哈哈。👻
Em....差不多半个月没来写文章了,没办法,近来工作繁忙😔,而今天在写文章时,发现文章发的贴图有大小限制了❗❗❗
文章封面图也放不了 .gif
格式的图了,唉...有点难受,俺还是喜欢贴一个动图在封面,每次找文章回顾就知道这篇文章大概是写了啥,这不挺好?
不过,算了,无所谓啦,这都不重要。😂
好,收,又扯远了,回到正题,这次要分享一个与 WebRTC
技术相关的内容,具体效果如下,请诸君按需食用。
(gif
压缩之后就有点糊了。。。由于两个用户都是在小编本地,所以摄像头看到的都是同一个画面)
什么是WebRTC?
一种基于浏览器的多媒体即时通信技术,🌐能实现在浏览器之间交换任意数据而无需中间件的技术。
(听起来就很厉害的样子😁)
MDN解释:传送门。
诞生背景:
随着互联网技术的发展,用户对于实时通信的需求不断增长,特别是在社交网络、在线教育和远程工作等领域。在WebRTC出现之前,若要在网页浏览器中进行实时音视频通信,通常需要依赖于插件,如 Flash
,但这并非一个高效或者安全的解决方案。
2011年,Google开源了WebRTC项目,旨在为浏览器提供无需额外插件或安装程序的实时音视频通信能力。
WebRTC能做些什么?
WebRTC
因其开放性、跨平台性和低延迟特性,被广泛应用于各种实时通信场景。
- 音视频通信:微信-视频通话、语音通话等。
- 多人视频会议:腾讯会议、钉钉会议等。
- 直播服务:实时直播,如游戏直播,观众可以通过WebRTC与直播者进行实时互动。
- 即时消息:用户在网页上发送和接收即时消息。
- 数据共享:用户可以在通话过程中实时共享文件,或者共享屏幕。
- 视频监控:实时监控某区域目标安全等等。
getUserMedia()
先来介绍一个很关键、基础的API,它也是咱们实现视频通话的基础。
猪脚👉:navigator.mediaDevices.getUserMedia(constraints, successCallback, errorCallback);
navigator.mediaDevices
:只读属性,会返回一个 MediaDevices 对象,该对象可提供对摄像头、麦克风或相机等媒体输入设备以及屏幕共享的连接访问。
(说白了就是这个对象能让你操作电脑的一些硬件设备😗)
如:
navigator.mediaDevices.getUserMedia(constraints)
:调用该方法会先向用户获取使用硬件的许可,用户同意后,会产生一个 MediaStream 流对象,这个对象可能是视频、音频或者其他类型的流对象,这取决 constraints 的配置。
不懂的,再瞧瞧MDN的解释:传送门。
说那么多不如写个示例感受感受😗:
<!DOCTYPE html>
<html>
<head>
<style>
#video {
width: 300px;
height: 300px;
background-color: #f5f5f5;
object-fit: cover;
}
</style>
</head>
<body>
<video id="video"></video><br />
<button id="startVideo">打开视频</button>
<button id="stopVideo">暂停播放</button>
<button id="continueVideo">继续播放</button>
<button id="closeVideo">关闭播放</button>
<script>
document.addEventListener('DOMContentLoaded', () => {
const video = document.getElementById('video');
const startVideo = document.getElementById('startVideo');
const stopVideo = document.getElementById('stopVideo');
const continueVideo = document.getElementById('continueVideo');
const closeVideo = document.getElementById('closeVideo');
startVideo.addEventListener('click', async () => {
// 获取一个音视频流(MediaStream)
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true,
});
// 将音视频流赋值video
video.srcObject = stream;
// 播放音视频流内容
video.play();
});
stopVideo.addEventListener('click', () => {
video.pause();
});
continueVideo.addEventListener('click', () => {
video.play();
});
closeVideo.addEventListener('click', () => {
video.srcObject = null;
});
});
</script>
</body>
</html>
效果:
可以看到调用该方法后,首先会向用户获取硬件使用许可(拒绝就没得玩了😶),这里是获取了摄像头与麦克风的使用许可,当用户同意后,浏览器就能通过 video
标签实时播放电脑摄像头的画面;并且麦克风也会进行使用,你可以尝试说话,然后听听看有没有声音传出。😉
上面示例代码中,咱们将获取到的 MediaStream 流对象赋值给了 video
的 srcObject 属性,这个属性可能大家接触比较少,反正呢,它能帮我们处理 MediaStream、MediaSource、Blob 或者一个 File 类型(该类型继承自 Blob
)的对象,先就这么硬记吧。👻
(结论:navigator.mediaDevices.getUserMedia()
方法能帮我们调用摄像头或麦克风?😮)
拍照功能
经过上面一个小案例,相信你对 getUserMedia()
方法有了一定的感受,接下来,我们再拿这个方法来实现一个常见功能-拍照。
<!DOCTYPE html>
<html>
<head>
<style>
#video, #canvas {
width: 300px;
height: 300px;
background-color: #f5f5f5;
object-fit: cover;
}
</style>
</head>
<body>
<!-- ... -->
<button id="takePhoto">拍照</button><br />
<canvas id="canvas"></canvas>
<script>
document.addEventListener('DOMContentLoaded', () => {
...
const takePhoto = document.getElementById('takePhoto');
// ...
takePhoto.addEventListener('click', () => {
const canvas = document.getElementById('canvas');
const context = this.canvas.getContext('2d');
context.drawImage(video, 0, 0, 300, 150);
});
});
</script>
</body>
</html>
效果:
当然,咱们也可以将图片下载下来:
takePhoto.addEventListener('click', () => {
const canvas = document.getElementById('canvas');
const context = this.canvas.getContext('2d');
context.drawImage(video, 0, 0, 300, 150);
// 下载图片
const url = canvas.toDataURL('images/png')
const a = document.createElement('a');
const event = new MouseEvent('click');
a.download = 'default.png';
a.href = url;
a.dispatchEvent(event);
});
其实,拍照这个功能网上有很多文章介绍了,大多也是使用 getUserMedia()
方法进行实现的,需要更详细的介绍,可以再去搜索搜索看哈,咱们加深一下感受就可以啦😉。
Socket.IO
接下来,再来介绍一个实时通信的库,这也是咱们最终案例会使用到的一个东东😶,我们单独先来看看。
Socket.IO 是一个库,可以在客户端和服务器之间实现低延迟、双向和基于事件的通信。
Socket.IO官方文档:传送门
这个库主要有两个包:socket.io 与 socket.io-client,对应服务端与客户端的场景。
基础使用
咱们使用它们来写个小案例耍耍吧😀。
先随便找个空文件夹,npm init -y
初始化一下 package.json
文件。
然后,安装依赖:
npm install socket.io socket.io-client nodemon
为了方便,小编安装了 nodemon 包,它能帮我们在每次修改服务端代码保存后自动重启服务,省了手动重启的麻烦。
开始搭建服务,创建一个 server.js
文件:
const http = require('http');
const socket = require('socket.io');
// 创建服务
const server = http.createServer();
// 初始化socket.io
const io = socket(server, {
cors: {
origin: '*', // 允许跨域
}
});
// 启动监听
io.on('connection', socket => {
// 给客户端发送connectionSuccess事件
socket.emit('connectionSuccess');
});
server.listen(3000, () => {
console.log('服务启动:http://localhost:3000/');
});
上述,咱们创建了一个 http
服务,然后初始化了 socket.io
的监听,当监听到有新连接过来的时候,则会发出一个 connectionSuccess
事件。
connection
事件也可以改成它的别名:connect
。
配置启动命令并执行:
{
"scripts": {
"server": "nodemon server.js"
}
}
好,服务端就这样简单搭建完了,接下来搞一下客户端。
创建一个 index.html
文件:
<!DOCTYPE html>
<html>
<body>
<h1>橙某人</h1>
<script type="module">
import { io } from "https://cdn.socket.io/4.7.5/socket.io.esm.min.js";
document.addEventListener('DOMContentLoaded', () => {
const socket = io('http://localhost:3000/');
socket.on('connectionSuccess', () => {
console.log('连接成功')
});
});
</script>
</body>
</html>
打开页面,如果能看到浏览器控制台与服务端有响应的话,就说明服务端与客户端已经连接上了。🙏
上面的代码中,服务端从中给客户端主动发送消息了(connectionSuccess
),客户端能监听并接收到。
咱们再来尝试从客户端给服务端主动发送消息,如果服务端也能监听并接收到,就能完整说明两端已经通了,咱们能随时在两端进行实时通信。
<!DOCTYPE html>
<html>
<body>
<input id="input" placeholder="请输入内容" />
<button id="btn">向服务端发送消息</button>
<script type="module">
import { io } from "https://cdn.socket.io/4.7.5/socket.io.esm.min.js";
document.addEventListener('DOMContentLoaded', () => {
// ...
const btn = document.getElementById('btn');
const input = document.getElementById('input');
btn.addEventListener('click', () => {
socket.emit('hello', `我是客户端发来的消息:${input.value}`);
});
});
</script>
</body>
</html>
服务端:
// ...
// 启动监听
io.on('connection', socket => {
socket.emit('connectionSuccess');
// 监听hello事件
socket.on('hello', content => {
console.log(content);
});
});
// ...
好,真通了🙏。
是不是挺简单😁,记住监听用 on
,发送就用 emit
,完事。
房间
上面示例中,当我们创建多个客户端,如浏览器创建多个Tab页面,这时只要服务端发送消息过来,所有的Tab页面都会接收到,这显然在某些场景下不是我们所期盼的❌,此时呢,就会引出一个"房间"的概念。
接下来,咱们来瞅瞅这个房间要如何使用,先看效果:
从图中可以看到,有三个客户端,其中上下两个客户端都是加入 123
这个房间,中间客户端加入了 123456
房间;当我们在 123
房间内发送消息,中间的客户端是接收不到的;同理,如果中间的客户端也发送消息,上下的客户端也是接收不到的;只有加入对应房间的客户端才能接收到,这就是房间的作用,相信都能理解哈😂。
来看看如何实现的,客户端:
<!DOCTYPE html>
<html>
<body>
<input id="inputRoom" placeholder="请输入房间号" />
<button id="joinBtn">加入房间</button>
<div id="tip"></div>
<input id="inputContent" placeholder="请输入内容" />
<button id="sendBtn">发送消息</button>
<script type="module">
import { io } from "https://cdn.socket.io/4.7.5/socket.io.esm.min.js";
document.addEventListener('DOMContentLoaded', () => {
const socket = io('http://localhost:3000/');
socket.on('connectionSuccess', () => {
console.log('连接成功')
});
const inputRoom = document.getElementById('inputRoom');
const joinBtn = document.getElementById('joinBtn');
const tip = document.getElementById('tip');
const inputContent = document.getElementById('inputContent');
const sendBtn = document.getElementById('sendBtn');
joinBtn.addEventListener('click', () => {
const roomId = inputRoom.value;
socket.emit('joinRoom', roomId);
});
socket.on('roomInfo', roomId => {
tip.innerText = `成功加入${roomId}房间`;
});
sendBtn.addEventListener('click', () => {
const roomId = inputRoom.value;
const content = inputContent.value;
socket.emit('content', {roomId, content});
});
socket.on('message', result => {
console.log(`服务端发来的消息:${result}`);
});
});
</script>
</body>
</html>
服务端:
const http = require('http');
const socket = require('socket.io');
const server = http.createServer();
const io = socket(server, {
cors: {
origin: '*'
}
});
io.on('connect', socket => {
socket.emit('connectionSuccess');
socket.on('hello', message => {
console.log('客户端发来的消息:', message);
});
socket.on('joinRoom', roomId => {
// 加入房间,没有就会创建房间
socket.join(roomId);
// 往房间内的客户端发送roomInfo事件
io.to(roomId).emit('roomInfo', roomId)
});
socket.on('content', ({ roomId, content }) => {
// 往房间内的客户端发送message事件
io.to(roomId).emit('message', content)
});
});
server.listen(3000, () => {
console.log('服务启动:http://localhost:3000/');
});
主要就是认识了一下 socket.join()
与 io.to()
方法的使用,暂时也就足够了,更多"房间"的内容:传送门。
视频通话的实现
好了,有上述这些内容作为基础,接下来我们就可以开始探索如何实现视频通话的功能。
先来看看服务端的代码实现:
const socket = require('socket.io');
const http = require('http');
const server = http.createServer()
const io = socket(server, {
cors: {
origin: '*'
}
});
io.on('connection', socket => {
// 向客户端发送连接成功的消息
socket.emit('connectionSuccess');
// 加入房间
socket.on('joinRoomEvent', roomId => {
socket.join(roomId);
})
// 申请通话
socket.on('callEvent', roomId => {
io.to(roomId).emit('callEvent');
});
// 同意通话
socket.on('acceptCallEvent', roomId => {
io.to(roomId).emit('acceptCallEvent');
});
// 传送offer
socket.on('offerEvent', ({ offer, roomId }) => {
io.to(roomId).emit('offerEvent', offer);
});
// 传送Answer
socket.on('answerEvent', ({ answer, roomId }) => {
io.to(roomId).emit('answerEvent', answer);
});
// 传送candidate
socket.on('candidateEvent', ({ candidate, roomId }) => {
io.to(roomId).emit('candidateEvent', candidate)
});
// 挂断通话
socket.on('end', roomId => {
io.to(roomId).emit('end');
});
})
server.listen(3000, () => {
console.log('服务器启动成功');
});
服务端很简单,通过 socket.io
包咱们一共创建八个监听事件,用于服务客户端的消息交流。额外需要关注的是 offer
、 answer
与 candidate
事件😯,它们是通话的关键。
启动服务与上面的一样,这里就不过多累述了,咱再来看看客户端的实现过程。
用户A:
<!DOCTYPE html>
<html>
<head>
<title>用户A-语音通话</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="box">
<h1>用户A</h1>
<div class="container">
<video id="videoA"></video>
<div class="container-small">
<video id="videoASmall"></video>
</div>
<div id="layerA" class="layer">
<div class="layer__text">正在等待对方接受邀请...</div>
<div class="layer__btn">
<img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6400a568de2e4a57b04e684be4004d52~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=48&h=48&s=870&e=png&a=1&b=f53c36" />
<span>取消</span>
</div>
</div>
</div>
<div>
<button id="videoBtn">视频通话</button>
<button>语音通话</button>
</div>
</div>
<script type="module">
import { io } from "https://cdn.socket.io/4.7.5/socket.io.esm.min.js";
document.addEventListener('DOMContentLoaded', () => {
// 初始化socket
const socket = io('http://localhost:3000/');
const roomId = '123456';
socket.on('connectionSuccess', () => {
console.log('连接成功');
socket.emit('joinRoomEvent', roomId);
});
const videoBtn = document.getElementById('videoBtn');
const layerA = document.getElementById('layerA');
// video
const videoA = document.getElementById('videoA');
const videoASmall = document.getElementById('videoASmall');
// stream
let streamA = null;
// 呼叫中
let calling = false;
// 通话中
let communicating = false;
// 视频通话
videoBtn.addEventListener('click', async () => {
calling = true;
layerA.style.display = 'flex';
const stream = await getLocalStream();
videoA.srcObject = stream;
videoA.play()
streamA = stream;
// 申请通话
socket.emit('callEvent', roomId);
});
let peer = null;
// 监听同意接听事件
socket.on('acceptCallEvent', async () => {
peer = new RTCPeerConnection();
// 添加本地"音视频流"
for (const track of streamA.getTracks()) {
peer.addTrack(track, streamA);
}
peer.onicecandidate = event => {
if (event.candidate) {
// 将candidate发送给对方
socket.emit('candidateEvent', { roomId, candidate: event.candidate });
}
}
peer.ontrack = event => {
calling = false;
layerA.style.display = 'none';
communicating = true;
videoASmall.srcObject = event.streams[0];
// 让video缓一下,再播放,否则可能存在流还没添加上,就播放视频而报错
setTimeout(() => {
videoASmall.play();
})
}
// 生成offer
const offer = await peer.createOffer({
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
});
// 设置本地描述的offer
await peer.setLocalDescription(offer);
// 将offer发送给对方
socket.emit('offerEvent', { roomId, offer });
});
socket.on('answerEvent', answer => {
// 设置"远端"描述的answer
peer.setRemoteDescription(answer);
})
socket.on('candidateEvent', async candidate => {
await peer.addIceCandidate(candidate);
});
/** @name 获取本地音视频流 **/
async function getLocalStream() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
return stream;
}
});
</script>
</body>
</html>
网上看到一些关于 WebRTC
文章,有些在将本地"音视频流"添加到轨道集合中时,使用的是addStream这个方法,MDN上描述已经是废弃的方法:
试了一下,虽然还能用。但是,MDN推荐还是使用 addTrack 方法,当然,如果使用 addTrack
方法,那么在 ontrack
方法中获取的时候,也要改成使用 event.streams[0]
形式了,这点需要注意一下。
用户B:
<!DOCTYPE html>
<html>
<head>
<title>用户B-语音通话</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="box">
<h1>用户B</h1>
<div class="container">
<video id="videoB"></video>
<div class="container-small">
<video id="videoBSmall"></video>
</div>
<div id="layerB" class="layer">
<div class="layer__text">用户A邀请你视频通话...</div>
<div class="layer__btns">
<div id="rejectBtn" class="layer__btn">
<img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6400a568de2e4a57b04e684be4004d52~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=48&h=48&s=870&e=png&a=1&b=f53c36" />
<span>挂断</span>
</div>
<div id="acceptBtn" class="layer__btn">
<img src="https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/11f90e68b4074fd899d3d512ef9bba5f~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=48&h=48&s=881&e=png&a=1&b=47de66" />
<span>接听</span>
</div>
</div>
</div>
</div>
</div>
<script type="module">
import { io } from "https://cdn.socket.io/4.7.5/socket.io.esm.min.js";
document.addEventListener('DOMContentLoaded', () => {
const socket = io('http://localhost:3000/');
const roomId = '123456';
socket.on('connectionSuccess', () => {
console.log('连接成功');
socket.emit('joinRoomEvent', roomId);
});
const layerB = document.getElementById('layerB');
const videoB = document.getElementById('videoB');
const videoBSmall = document.getElementById('videoBSmall');
let streamB = null;
let calling = false;
let communicating = false;
// 监听申请通话的事件
socket.on('callEvent', async () => {
calling = true;
layerB.style.display = 'flex';
const stream = await getLocalStream();
videoB.srcObject = stream;
videoB.play()
streamB = stream;
});
const acceptBtn = document.getElementById('acceptBtn');
// 同意接听
acceptBtn.addEventListener('click', () => {
socket.emit('acceptCallEvent', roomId);
});
let peer = null;
// 监听offer事件
socket.on('offerEvent', async offer => {
peer = new RTCPeerConnection();
for (const track of streamB.getTracks()) {
peer.addTrack(track, streamB);
}
peer.onicecandidate = event => {
if (event.candidate) {
socket.emit('candidateEvent', { roomId, candidate: event.candidate });
}
}
peer.ontrack = event => {
calling = false;
layerB.style.display = 'none';
communicating = true;
videoBSmall.srcObject = event.streams[0];
setTimeout(() => {
videoBSmall.play();
})
}
// 设置"远端"描述的offer
await peer.setRemoteDescription(offer);
// 生成answer
const answer = await peer.createAnswer();
// 设置本地描述的answer
await peer.setLocalDescription(answer);
// 将answer发送给对方
socket.emit('answerEvent', { roomId, answer })
});
socket.on('candidateEvent', async candidate => {
await peer.addIceCandidate(candidate);
});
async function getLocalStream() {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
return stream;
}
});
</script>
</body>
</html>
样式:
body {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
}
.box {
display: flex;
flex-direction: column;
align-items: center;
}
.box:first-child {
margin-right: 20px;
}
.container {
width: 370px;
height: 580px;
background-color: #f5f5f5;
margin-bottom: 20px;
position: relative;
}
button {
width: 100px;
height: 40px;
cursor: pointer;
background-color: #fff;
border: 1px solid #ccc;
color: #555;
font-size: 15px;
}
button:first-child {
margin-right: 20px;
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.container-small {
width: 120px;
height: 160px;
position: absolute;
right: 0;
bottom: 0;
z-index: 1;
}
.layer {
width: 100%;
height: 100%;
position: absolute;
z-index: 2;
top: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.8);
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
display: none;
}
.layer__text {
margin-top: 150px;
margin-bottom: 260px;
}
.layer__btns {
display: flex;
}
.layer__btns .layer__btn:last-child {
margin-left: 100px;
}
.layer__btn {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 12px;
cursor: pointer;
}
.layer__btn img {
border-radius: 50%;
background-color: #fff;
margin-bottom: 8px;
}
整个功能最关键API:
RTCPeerConnection:本地端和远程对等端之间的 WebRTC
连接。它提供了创建远程对等端连接、维护和监视连接,以及在连接不再需要时关闭连接的方法。
呃...也没什么好介绍这个API,咱们用就完事。😁
而从代码里面可以看到,用户A与用户B的代码都差不多,主要过程大概是:
- 用户A生成了一个
offer
的东东,通过setLocalDescription
方法修改了自己本地端的描述,然后将它发给了用户B。 - 用户B也生成了一个
answer
的东东,也一样通过setLocalDescription
方法修改了自己本地端的描述,然后将它发给了用户A。 - 最后,用户A与用户B通过使用
setRemoteDescription
方法,将对方发过来的offer
与answer
修改到自己远程端的描述。
这个过程呢,一般就是固定的,目的就是为了要打通两端之间的 WebRTC
连接,实现一个即时通信能力。
贴一个网上流传比较广,完整绘制了整个建立过程的图:
还有还有,咱们也需要关注 onicecandidate
与 ontrack
这两个事件的触发时机:
- 调用
setLocalDescription
方法修改本地端描述时,会触发onicecandidate
事件。 - 调用
setRemoteDescription
方法修改远程端描述时,则会触发ontrack
事件。
原理过程
此处省略一万字。。。
本来寻思着讲讲 WebRTC
的原理过程,但是,了解完一圈下来后,感觉太复杂了,好像不是自己能轻易讲得清楚的,写完可能还没网上现成的写得好呢,算啦算啦。🤡
找了两篇写得不错的文章,感兴趣的UU们可以自行观摩观摩:
至此,本篇文章就写完啦,撒花撒花。
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。