介绍
WebSocket 是允许在服务器和客户端之间进行全双工通信的 Internet 协议。该协议超越了典型的 HTTP 请求和响应范式。使用 WebSockets,服务器可以在客户端不发起请求的情况下向客户端发送数据,从而允许一些非常有趣的应用程序。
在本教程中,您将构建一个实时文档协作应用程序(类似于 Google Docs)。我们将使用Socket.IO Node.js 服务器框架和Angular 7来实现这一点。
您可以在 GitHub 上找到此示例项目的完整源代码。
先决条件
要完成本教程,您需要:
- Node.js 安装在本地,您可以按照如何安装 Node.js 和创建本地开发环境来完成。
- 支持 WebSocket 的现代 Web 浏览器。
本教程最初是在由 Node.js v8.11.4、npm v6.4.1 和 Angular v7.0.4 组成的环境中编写的。
本教程已通过 Node v14.6.0、npm v6.14.7、Angular v10.0.5 和 Socket.IO v2.3.0 验证。
步骤 1 — 设置项目目录并创建套接字服务器
首先,打开您的终端并创建一个新的项目目录,其中将包含我们的服务器和客户端代码:
- mkdir socket-example
接下来,切换到项目目录:
- cd socket-example
然后,为服务器代码创建一个新目录:
- mkdir socket-server
接下来,切换到服务器目录。
- cd socket-server
然后,初始化一个新npm
项目:
- npm init -y
现在,我们将安装我们的软件包依赖项:
- npm install express@4.17.1 socket.io@2.3.0 @types/socket.io@2.1.10 --save
这些软件包包括 Express、Socket.IO 和@types/socket.io
.
现在您已经完成了项目的设置,您可以继续为服务器编写代码。
首先,创建一个新src
目录:
- mkdir src
现在,app.js
在src
目录中创建一个名为的新文件,并使用您喜欢的文本编辑器打开它:
- nano src/app.js
从require
Express 和 Socket.IO的语句开始:
const app = require('express')();
const http = require('http').Server(app);
const io = require('socket.io')(http);
如您所知,我们使用 Express 和 Socket.IO 来设置我们的服务器。Socket.IO 在原生 WebSocket 上提供了一个抽象层。它带有一些不错的功能,例如不支持 WebSockets 的旧浏览器的回退机制,以及创建房间的能力。我们将在一分钟内看到这一点。
为了我们的实时文档协作应用程序的目的,我们需要一种方法来存储documents
. 在生产环境中,您可能希望使用数据库,但在本教程的范围内,我们将使用以下内存存储documents
:
const documents = {};
现在,让我们定义我们希望套接字服务器实际执行的操作:
io.on("connection", socket => {
// ...
});
让我们分解一下。.on('...')
是一个事件监听器。第一个参数是事件的名称,第二个参数通常是在事件触发时执行的回调,以及事件负载。
我们看到的第一个例子是当客户端连接到套接字服务器时(connection
是 Socket.IO 中的保留事件类型)。
我们得到一个socket
变量传递给我们的回调以启动与该一个套接字或多个套接字的通信(即广播)。
safeJoin
我们将设置一个本地函数 ( safeJoin
) 来处理加入和离开房间:
io.on("connection", socket => {
let previousId;
const safeJoin = currentId => {
socket.leave(previousId);
socket.join(currentId, () => console.log(`Socket ${socket.id} joined room ${currentId}`));
previousId = currentId;
};
// ...
});
在这种情况下,当客户加入房间时,他们正在编辑特定文档。因此,如果多个客户在同一个房间里,他们都在编辑同一个文档。
从技术上讲,一个socket可以在多个房间,但是我们不想让一个客户端同时编辑多个文档,所以如果他们切换文档,我们需要离开之前的房间,加入新的房间。这个小功能可以解决这个问题。
我们的套接字正在从客户端侦听三种事件类型:
getDoc
addDoc
editDoc
以及我们的套接字向客户端发出的两种事件类型:
document
documents
getDoc
让我们处理第一个事件类型 – getDoc
:
io.on("connection", socket => {
// ...
socket.on("getDoc", docId => {
safeJoin(docId);
socket.emit("document", documents[docId]);
});
// ...
});
当客户端发出getDoc
事件时,套接字将获取有效负载(在我们的例子中,它只是一个 id),加入一个房间docId
,并将存储的document
仅发送回发起客户端。这就是socket.emit('document', ...)
发挥作用的地方。
addDoc
让我们处理第二种事件类型 – addDoc
:
io.on("connection", socket => {
// ...
socket.on("addDoc", doc => {
documents[doc.id] = doc;
safeJoin(doc.id);
io.emit("documents", Object.keys(documents));
socket.emit("document", doc);
});
// ...
});
对于addDoc
事件,有效负载是一个document
对象,目前仅包含客户端生成的 id。我们告诉我们的套接字加入该 ID 的房间,以便将来的任何编辑都可以广播给同一房间中的任何人。
接下来,我们希望连接到我们服务器的每个人都知道有一个新文档要处理,因此我们向所有具有该io.emit('documents', ...)
功能的客户端广播。
注意之间的差异socket.emit()
和io.emit()
-的socket
版本是用于发射回只启动客户端,io
版本是用于发射给大家连接到我们的服务器。
editDoc
让我们处理第三种事件类型 – editDoc
:
io.on("connection", socket => {
// ...
socket.on("editDoc", doc => {
documents[doc.id] = doc;
socket.to(doc.id).emit("document", doc);
});
// ...
});
对于该editDoc
事件,有效载荷将是在任何击键后处于其状态的整个文档。我们将替换数据库中的现有文档,然后将新文档广播给当前正在查看该文档的客户端。我们通过调用socket.to(doc.id).emit(document, doc)
来做到这一点,它向该特定房间中的所有套接字发出信号。
最后,每当建立新连接时,我们都会向所有客户端广播,以确保新连接在连接时收到最新的文档更改:
io.on("connection", socket => {
// ...
io.emit("documents", Object.keys(documents));
console.log(`Socket ${socket.id} has connected`);
});
套接字功能都设置好后,选择一个端口并监听它:
http.listen(4444, () => {
console.log('Listening on port 4444');
});
在终端中运行以下命令以启动服务器:
- node src/app.js
我们现在有一个用于文档协作的全功能套接字服务器!
第 2 步 – 安装@angular/cli
和创建客户端应用程序
打开一个新的终端窗口并导航到项目目录。
运行以下命令以将 Angular CLI 安装为devDependency
:
- npm install @angular/cli@10.0.4 --save-dev
现在,使用@angular/cli
命令创建一个新的 Angular 项目,没有 Angular Routing 和 SCSS 样式:
- ng new socket-app --routing=false --style=scss
然后,切换到服务器目录:
- cd socket-app
现在,我们将安装我们的软件包依赖项:
- npm install ngx-socket-io@3.2.0 --save
ngx-socket-io
是 Socket.IO 客户端库上的 Angular 包装器。
然后,使用@angular/cli
命令生成document
模型、document-list
组件、document
组件和document
服务:
- ng generate class models/document --type=model
- ng generate component components/document-list
- ng generate component components/document
- ng generate service services/document
现在您已经完成了项目的设置,您可以继续为客户端编写代码。
应用模块
打开app.modules.ts
:
- nano src/app/app.module.ts
并导入FormsModule
, SocketioModule
, 和SocketioConfig
:
// ... other imports
import { FormsModule } from '@angular/forms';
import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';
在您的@NgModule
声明之前,定义config
:
const config: SocketIoConfig = { url: 'http://localhost:4444', options: {} };
您会注意到这是我们之前在服务器的app.js
.
现在,添加到您的imports
数组中,看起来像:
@NgModule({
// ...
imports: [
// ...
FormsModule,
SocketIoModule.forRoot(config)
],
// ...
})
这将在AppModule
加载后立即触发与我们的套接字服务器的连接。
文档模型和文档服务
打开document.model.ts
:
- nano src/app/models/document.model.ts
并定义id
和doc
:
export class Document {
id: string;
doc: string;
}
打开document.service.ts
:
- nano src/app/services/document.service.ts
并在类定义中添加以下内容:
import { Injectable } from '@angular/core';
import { Socket } from 'ngx-socket-io';
import { Document } from 'src/app/models/document.model';
@Injectable({
providedIn: 'root'
})
export class DocumentService {
currentDocument = this.socket.fromEvent<Document>('document');
documents = this.socket.fromEvent<string[]>('documents');
constructor(private socket: Socket) { }
getDocument(id: string) {
this.socket.emit('getDoc', id);
}
newDocument() {
this.socket.emit('addDoc', { id: this.docId(), doc: '' });
}
editDocument(document: Document) {
this.socket.emit('editDoc', document);
}
private docId() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 5; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
}
此处的方法分别表示套接字服务器正在侦听的三种事件类型。属性currentDocument
和documents
表示套接字服务器发出的事件,在客户端上作为Observable
.
您可能会注意到对 的调用this.docId()
。这是一个小的私有方法,它生成一个随机字符串作为文档 id 分配。
文档列表组件
让我们将文档列表放在 sidenav 中。现在,它只显示docId
– 一个随机字符串。
打开document-list.component.html
:
- nano src/app/components/document-list/document-list.component.html
并将内容替换为以下内容:
<div class='sidenav'>
<span
(click)='newDoc()'
>
New Document
</span>
<span
[class.selected]='docId === currentDoc'
(click)='loadDoc(docId)'
*ngFor='let docId of documents | async'
>
{{ docId }}
</span>
</div>
打开document-list.component.scss
:
- nano src/app/components/document-list/document-list.component.scss
并添加一些样式:
.sidenav {
background-color: #111111;
height: 100%;
left: 0;
overflow-x: hidden;
padding-top: 20px;
position: fixed;
top: 0;
width: 220px;
span {
color: #818181;
display: block;
font-family: 'Roboto', Tahoma, Geneva, Verdana, sans-serif;
font-size: 25px;
padding: 6px 8px 6px 16px;
text-decoration: none;
&.selected {
color: #e1e1e1;
}
&:hover {
color: #f1f1f1;
cursor: pointer;
}
}
}
打开document-list.component.ts
:
- nano src/app/components/document-list/document-list.component.ts
并在类定义中添加以下内容:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { DocumentService } from 'src/app/services/document.service';
@Component({
selector: 'app-document-list',
templateUrl: './document-list.component.html',
styleUrls: ['./document-list.component.scss']
})
export class DocumentListComponent implements OnInit, OnDestroy {
documents: Observable<string[]>;
currentDoc: string;
private _docSub: Subscription;
constructor(private documentService: DocumentService) { }
ngOnInit() {
this.documents = this.documentService.documents;
this._docSub = this.documentService.currentDocument.subscribe(doc => this.currentDoc = doc.id);
}
ngOnDestroy() {
this._docSub.unsubscribe();
}
loadDoc(id: string) {
this.documentService.getDocument(id);
}
newDoc() {
this.documentService.newDocument();
}
}
让我们从属性开始。documents
将是所有可用文档的流。currentDocId
是当前选定文档的 ID。文档列表需要知道我们在哪个文档上,因此我们可以在 sidenav 中突出显示该文档 ID。_docSub
是对Subscription
提供当前或选定文档的的引用。我们需要这个,以便我们可以在ngOnDestroy
生命周期方法中取消订阅。
您会注意到这些方法,loadDoc()
并且newDoc()
不会返回或分配任何内容。请记住,这些会向套接字服务器触发事件,套接字服务器会反过来将事件触发回我们的 Observables。获取现有文档或添加新文档的返回值由上述Observable
模式实现。
文档组件
这将是文档编辑界面。
打开document.component.html
:
- nano src/app/components/document/document.component.html
并将内容替换为以下内容:
<textarea
[(ngModel)]='document.doc'
(keyup)='editDoc()'
placeholder='Start typing...'
></textarea>
打开document.component.scss
:
- nano src/app/components/document/document.component.scss
并更改默认 HTML 的一些样式textarea
:
textarea {
border: none;
font-size: 18pt;
height: 100%;
padding: 20px 0 20px 15px;
position: fixed;
resize: none;
right: 0;
top: 0;
width: calc(100% - 235px);
}
打开document.component.ts
:
- src/app/components/document/document.component.ts
并在类定义中添加以下内容:
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { Document } from 'src/app/models/document.model';
import { DocumentService } from 'src/app/services/document.service';
@Component({
selector: 'app-document',
templateUrl: './document.component.html',
styleUrls: ['./document.component.scss']
})
export class DocumentComponent implements OnInit, OnDestroy {
document: Document;
private _docSub: Subscription;
constructor(private documentService: DocumentService) { }
ngOnInit() {
this._docSub = this.documentService.currentDocument.pipe(
startWith({ id: '', doc: 'Select an existing document or create a new one to get started' })
).subscribe(document => this.document = document);
}
ngOnDestroy() {
this._docSub.unsubscribe();
}
editDoc() {
this.documentService.editDocument(this.document);
}
}
与我们在DocumentListComponent
上面使用的模式类似,我们将订阅当前文档的更改,并在我们更改当前文档时向套接字服务器触发一个事件。这意味着如果任何其他客户正在编辑与我们相同的文档,我们将看到所有更改,反之亦然。startWith
当我们的用户第一次打开应用程序时,我们使用 RxJS操作符给他们一个小消息。
应用组件
打开app.component.html
:
- nano src/app.component.html
并通过将内容替换为以下内容来组合两个自定义组件:
<app-document-list></app-document-list>
<app-document></app-document>
第 3 步 — 查看应用程序运行情况
我们的套接字服务器仍在终端窗口中运行,让我们打开一个新的终端窗口并启动我们的 Angular 应用程序:
- ng serve
http://localhost:4200
在单独的浏览器选项卡中打开多个实例并查看其运行情况。
现在,您可以创建新文档并在两个浏览器窗口中查看它们的更新。您可以在一个浏览器窗口中进行更改,并在另一个浏览器窗口中查看反映的更改。
结论
在本教程中,您已经完成了对使用 WebSocket 的初步探索。您使用它来构建实时文档协作应用程序。它支持多个浏览器会话以连接到服务器并更新和修改多个文档。
如果您想了解有关 Angular 的更多信息,请查看我们的 Angular 主题页面,了解练习和编程项目。
如果您想了解有关 Socket.IO 的更多信息,请查看集成 Vue.js 和 Socket.IO。
更多的 WebSocket 项目包括实时聊天应用程序。请参阅如何使用 React 和 GraphQL 构建实时聊天应用程序。