网站首页 > 技术文章 正文
话不多说,我们直奔主题。这篇文章教大家如何编写一个视频聊天应用,使已连接的两用户端能共享视频和音频。操作很简单,非常适合JavaScript语言训练——更准确地说是WebRTC技术和Node.js。
什么是WebRTC?
Web Real-Time Communications 网页实时通信,简称WebRTC。WebRTC是一个HTML5规范,它允许用户在浏览器之间直接进行实时通信,不需要任何第三方插件。WebRTC可用于多种情境(比如文件共享),但端对端实时音频和视频通信是其主要功能。本文将着重为大家介绍这两项。
WebRTC所做的就是允许接入设备。你可以借WebRTC来实时使用麦克风、摄像头和分享你的屏幕。
所以,WebRTC可以用最简单的方式在网页中实现音频和视频通信。
WebRTC JavaScript API
WebRTC说起来很复杂,它涉及到很多技术。但建立连接、通信和传输数据的操作是通过一套JS API来实现的,还比较简单。其中主要的API包括:
RTCPeerConnection:创建和导航端对端连接。
RTCSessionDescription:描述连接(或潜在连接)的一端,以及它的配置方式。
navigator.getUserMedia:捕捉音频和视频。
为什么选择Node.js?
若要在两个或多个设备之间进行远程连接,你就需要一个服务器。在这种情况下,你也需要一个处理实时通信的服务器。Node.js是为实时可扩展的应用而构建的。要开发自由数据交换的双向连接应用程序,你可能会用到WebSockets,它允许在客户端和服务器之间建立一个会话窗口。来自客户端的请求会以循环的方式,更准确地说是事件循环进行处理,这时Node.js是我们很好的一个选择,因为它采取 “非阻塞(non-blocking) “的方式来解决请求。这样我们在这该过程中就能实现低延迟和高吞吐量。
如果你对开发微服务感兴趣的话,一定要看看查看我们内含650多位微服务专家意见的2020年微服务状态报告!
思路拓展:我们要创建的是什么?
我们会创建一个非常简单的应用程序,它能让我们将音频和视频流传输到连接的设备——一个基础款视频聊天应用程序。我们会用到的技术有:
Express库,提供静态文件,比如代表用户界面(UI)的HTML文件;
socket.io库,在两个设备之间用WebSockets建立连接;
WebRTC,允许媒体设备(摄像头和麦克风)在连接的设备之间传输音频和视频流。
实现视频会话
我们要做的第一件事是给我们的应用程序提供一个作为UI的HTML文件。让我们通过运行:npm init.js来初始化新的node.js项目。然后,我们需要通过运行:npm i -D typescript ts-node nodemon @types/express @types/socket.io安装一些开发依赖项,运行:npm i express socket.io安装生产依赖项。
之后我们就可以在package.json文件中定义脚本,来运行我们的项目了。
{
"scripts": {
"start": "ts-node src/index.ts",
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts"
},
"devDependencies": {
"@types/express": "^4.17.2",
"@types/socket.io": "^2.1.4",
"nodemon": "^1.19.4",
"ts-node": "^8.4.1",
"typescript": "^3.7.2"
},
"dependencies": {
"express": "^4.17.1",
"socket.io": "^2.3.0"
}
}
当我们运行npm run dev命令时,nodemon会监控src文件夹中每个以.ts结尾的文件有无任何变化。现在,我们要创建一个src文件夹。在这个文件夹中,我们会创建两个typescript文件:index.ts和server.ts。
在server.ts中,我们会创建server类,并使其与express和socket.io一起工作。
import express, { Application } from "express";
import socketIO, { Server as SocketIOServer } from "socket.io";
import { createServer, Server as HTTPServer } from "http";
export class Server {
private httpServer: HTTPServer;
private app: Application;
private io: SocketIOServer;
private readonly DEFAULT_PORT = 5000;
constructor() {
this.initialize();
this.handleRoutes();
this.handleSocketConnection();
}
private initialize(): void {
this.app = express();
this.httpServer = createServer(this.app);
this.io = socketIO(this.httpServer);
}
private handleRoutes(): void {
this.app.get("/", (req, res) => {
res.send(`<h1>Hello World</h1>`);
});
}
private handleSocketConnection(): void {
this.io.on("connection", socket => {
console.log("Socket connected.");
});
}
public listen(callback: (port: number) => void): void {
this.httpServer.listen(this.DEFAULT_PORT, () =>
callback(this.DEFAULT_PORT)
);
}
}
为正常运行服务器,我们需要在index.ts文件中创建一个新的Server类实例并调用listen方法。
import { Server } from "./server";
const server = new Server();
server.listen(port => {
console.log(`Server is listening on http://localhost:${port}`);
});
现在,如果我们运行:npm run dev会看到下面这样的情景:
当打开浏览器,输入http://localhost:5000,我们应该注意到左上的 “Hello World “信息。
然后我们就可以在public/index.html中创建一个新的HTML文件了。
<!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>Dogeller</title>
<link
href="https://fonts.googleapis.com/css?family=Montserrat:300,400,500,700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./styles.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
</head>
<body>
<div class="container">
<header class="header">
<div class="logo-container">
<img src="./img/doge.png" alt="doge logo" class="logo-img" />
<h1 class="logo-text">
Doge<span class="logo-highlight">ller</span>
</h1>
</div>
</header>
<div class="content-container">
<div class="active-users-panel" id="active-user-container">
<h3 class="panel-title">Active Users:</h3>
</div>
<div class="video-chat-container">
<h2 class="talk-info" id="talking-with-info">
Select active user on the left menu.
</h2>
<div class="video-container">
<video autoplay class="remote-video" id="remote-video"></video>
<video autoplay muted class="local-video" id="local-video"></video>
</div>
</div>
</div>
</div>
<script src="./scripts/index.js"></script>
</body>
</html>
在这个新文件中,我们创建了两个视频元素:一个用于远程视频连接,另一个用于本地视频。你可能已经注意到我们也在导入本地脚本了。现在我们就来创建一个新的文件夹“脚本”,并在这个目录下创建index.js文件。至于样式,你可以从GitHub库中下载它们。
接下来你需要给浏览器提供index.html。首先,你需要告诉express你想提供哪些静态文件。为了实现这一点,我们决定在Server类中实现一个新方法。
private configureApp(): void {
this.app.use(express.static(path.join(__dirname, "../public")));
}
不要忘记在initialize中调用configureApp。
private initialize(): void {
this.app = express();
this.httpServer = createServer(this.app);
this.io = socketIO(this.httpServer);
this.configureApp();
this.handleSocketConnection();
}
当你输入http://localhost:5000后,你应该能看到你的index.html文件在运行。
下一步要实现的是允许摄像头和视频访问并将其流式传输到local-video元素。要做到这一点,你需要打开public/scripts/index.js文件,并用以下方法实现它。
navigator.getUserMedia(
{ video: true, audio: true },
stream => {
const localVideo = document.getElementById("local-video");
if (localVideo) {
localVideo.srcObject = stream;
}
},
error => {
console.warn(error.message);
}
);
当回到浏览器时,界面会出现一个提示请求访问你的媒体设备,在接受请求后,你电脑的摄像头就开始工作了。
更多细节详见A simple guide to concurrency in Node.js and a few traps that come with it。
如何处理socket连接?
接下来我们讲讲如何处理socket连接。我们需要将客户端与服务器连接起来。为此,我们将使用socket.io。在public/scripts/index.js中,添加以下代码:
this.io.on("connection", socket => {
const existingSocket = this.activeSockets.find(
existingSocket => existingSocket === socket.id
);
if (!existingSocket) {
this.activeSockets.push(socket.id);
socket.emit("update-user-list", {
users: this.activeSockets.filter(
existingSocket => existingSocket !== socket.id
)
});
socket.broadcast.emit("update-user-list", {
users: [socket.id]
});
}
}
页面刷新后,电脑会弹出一条消息,显示 “Socket已连接”
然后我们回到server.ts中,把已连接的socket存储在内存中,这只是为了保留唯一连接。所以,我们需要在Server类中添加一个新的私有字段,如下:
private activeSockets: string[] = [];
然后我们需要在socket连接中检查socket是否已经存在。如果不存在,把新的socket推送到内存中,并向已连接的用户发送数据。
this.io.on("connection", socket => {
const existingSocket = this.activeSockets.find(
existingSocket => existingSocket === socket.id
);
if (!existingSocket) {
this.activeSockets.push(socket.id);
socket.emit("update-user-list", {
users: this.activeSockets.filter(
existingSocket => existingSocket !== socket.id
)
});
socket.broadcast.emit("update-user-list", {
users: [socket.id]
});
}
}
你还需要在socket断开连接时及时响应,所以在socket连接中,你需要添加:
socket.on("disconnect", () => {
this.activeSockets = this.activeSockets.filter(
existingSocket => existingSocket !== socket.id
);
socket.broadcast.emit("remove-user", {
socketId: socket.id
});
});
客户端(即public/scripts/index.js)这边,你需要妥善处理那些信息:
socket.on("update-user-list", ({ users }) => {
updateUserList(users);
});
socket.on("remove-user", ({ socketId }) => {
const elToRemove = document.getElementById(socketId);
if (elToRemove) {
elToRemove.remove();
}
});
以下是 updateUserList 函数:
function updateUserList(socketIds) {
const activeUserContainer = document.getElementById("active-user-container");
socketIds.forEach(socketId => {
const alreadyExistingUser = document.getElementById(socketId);
if (!alreadyExistingUser) {
const userContainerEl = createUserItemContainer(socketId);
activeUserContainer.appendChild(userContainerEl);
}
});
}
以及createUserItemContainer函数:
function createUserItemContainer(socketId) {
const userContainerEl = document.createElement("div");
const usernameEl = document.createElement("p");
userContainerEl.setAttribute("class", "active-user");
userContainerEl.setAttribute("id", socketId);
usernameEl.setAttribute("class", "username");
usernameEl.innerHTML = `Socket: ${socketId}`;
userContainerEl.appendChild(usernameEl);
userContainerEl.addEventListener("click", () => {
unselectUsersFromList();
userContainerEl.setAttribute("class", "active-user active-user--selected");
const talkingWithInfo = document.getElementById("talking-with-info");
talkingWithInfo.innerHTML = `Talking with: "Socket: ${socketId}"`;
callUser(socketId);
});
return userContainerEl;
}
需要注意的是,我们给用户容器元素添加了一个可以调用callUser函数的点击监听器——但现在,它可以是一个空的函数。接下来,当运行两个浏览器窗口(其中一个作为私人窗口)时,你应该注意到你的Web应用程序中有两个已经连接的socket。
点击列表中的活跃用户,这时我们需要调用callUser函数。但是在实现之前,你还需要在window对象中声明两个类。
const { RTCPeerConnection, RTCSessionDescription } = window;
我们会在callUser函数用到这两个类:
async function callUser(socketId) {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(new RTCSessionDescription(offer));
socket.emit("call-user", {
offer,
to: socketId
});
}
现在我们要创建一个本地请求并发送给选定的用户。服务器会监听一个叫做call-user的事件、拦截请求并将其转发给选定的用户。让我们用server.ts来实现该操作:
socket.on("call-user", data => {
socket.to(data.to).emit("call-made", {
offer: data.offer,
socket: socket.id
});
});
对于客户端,你需要就call-made事件作出调整:
socket.on("call-made", async data => {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.offer)
);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(new RTCSessionDescription(answer));
socket.emit("make-answer", {
answer,
to: data.socket
});
});
之后,在你从服务器得到的请求上设置一个远程描述,并为这个请求创建一个答复。对于服务器端,你只需要将适当的数据传递给选定的用户即可。然后我们再在server.ts里面添加一个监听器。
socket.on("make-answer", data => {
socket.to(data.to).emit("answer-made", {
socket: socket.id,
answer: data.answer
});
});
对于客户端,我们需要处理 answer-made 事件。
socket.on("answer-made", async data => {
await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.answer)
);
if (!isAlreadyCalling) {
callUser(data.socket);
isAlreadyCalling = true;
}
});
我们可以使用标志isAlreadyCalling,它能帮助确保我们只需调用一次用户。
最后你需要做的是添加本地轨道,包括音频和视频到你的连接端。只有做到这一点,我们才能够与连接的用户共享视频和音频。要做到这一点,我们需要在navigator.getMediaDevice回调中调用peerConnection对象的addTrack函数。
navigator.getUserMedia(
{ video: true, audio: true },
stream => {
const localVideo = document.getElementById("local-video");
if (localVideo) {
localVideo.srcObject = stream;
}
stream.getTracks().forEach(track => peerConnection.addTrack(track, stream));
},
error => {
console.warn(error.message);
}
);
另外,我们还需要为ontrack事件添加一个适当的处理程序。
peerConnection.ontrack = function({ streams: [stream] }) {
const remoteVideo = document.getElementById("remote-video");
if (remoteVideo) {
remoteVideo.srcObject = stream;
}
};
如图示,我们已经从传递的对象中获取了流,并改变了远程视频中的srcObject来使用接收到的流。所以现在当你点击活跃用户后,你应该建立一个视频和音频连接,像下图这样:
欲了解细节,请参阅:Node.js and dependency injection – friends or foes?
现在你知道如何编写一个视频聊天应用了吧!
WebRTC是一个很大的话题,内容非常庞杂。如果你想了解它的运作原理,就需要花很大功夫。幸运的是,我们可以访问易于使用的JavaScript API,它可以帮助我们创建很简洁的应用程序,例如视频共享、聊天应用程序等等。
如果你想深入了解WebRTC,点击此WebRTC官方文档的链接。另外,我也推荐你阅读MDN的文档说明,它能帮助你更加了解此技术。
- 上一篇: 9 个你可能从未使用过的很棒的 CSS 属性
- 下一篇: 快速掌握前端开发中的常见错误
猜你喜欢
- 2024-11-21 延迟加载图像以提高Web网站性能的五种方法「实践」
- 2024-11-21 web前端学到什么程度就可以找工作了?学到什么程度可以涨薪?
- 2024-11-21 web前端学到什么程度就可以找工作了?
- 2024-11-21 flv.js源码知识点(下)
- 2024-11-21 iG战队让一追三夺得德玛西亚杯西安站冠军
- 2024-11-21 前端新趋势?有了Web Component,还用纠结Vue或React?
- 2024-11-21 Python Web全栈之旅11--Web前端●CSS知识点汇总,建议收藏
- 2024-11-21 快速掌握前端开发中的常见错误
- 2024-11-21 9 个你可能从未使用过的很棒的 CSS 属性
- 2024-11-21 WebRTC对等连接- 连接不同设备上的浏览器
- 标签列表
-
- content-disposition (47)
- nth-child (56)
- math.pow (44)
- 原型和原型链 (63)
- canvas mdn (36)
- css @media (49)
- promise mdn (39)
- readasdataurl (52)
- if-modified-since (49)
- css ::after (50)
- border-image-slice (40)
- flex mdn (37)
- .join (41)
- function.apply (60)
- input type number (64)
- weakmap (62)
- js arguments (45)
- js delete方法 (61)
- blob type (44)
- math.max.apply (51)
- js (44)
- firefox 3 (47)
- cssbox-sizing (52)
- js删除 (49)
- js for continue (56)
- 最新留言
-