如何使用 Socket.IO、Angular 和 Node.js 创建实时应用程序

简介

WebSocket是允许服务器和客户端之间进行全双工通信的互联网协议。该协议超越了典型的HTTP请求和响应范例。使用WebSockets,服务器可以在客户端不发起请求的情况下向客户端发送数据,从而允许一些非常有趣的应用程序。

在本教程中,您将构建一个实时文档协作应用程序(类似于Google Docs)。我们将使用Socket.IONode.js服务器框架和ANGLE 7]来实现这一点。

您可以在GitHub上找到完整的示例项目的源代码

前提条件

要完成本教程,您需要:

本教程最初是在Node.js v8.11.4、npm v6.4.1和Angular v7.0.4组成的环境中编写的。

本教程使用NodeV14.6.0、NPMv6.14.7、ANGLING v10.0.5和Socket.IO v2.3.0进行了验证。

第一步-设置项目目录并创建Socket服务器

首先,打开您的终端并创建一个新的项目目录,该目录将包含我们的服务器和客户端代码:

1mkdir socket-example

接下来,切换到项目目录:

1cd socket-example

然后,为服务器代码创建一个新目录:

1mkdir socket-server

接下来,切换到服务器目录。

1cd socket-server

然后,初始化一个新的npm项目:

1npm init -y

现在,我们将安装我们的包依赖项:

这些包包括Express、Socket.IO和@type/socket.io

现在您已经完成了项目的设置,您可以继续为服务器编写代码了。

首先,新建一个src目录:

1mkdir src

现在,在src目录中创建一个名为app.js的新文件,并使用您喜欢的文本编辑器打开它:

1nano src/app.js

从Express和Socket.IO的require语句开始:

1[label socket-server/src/app.js]
2const app = require('express')();
3const http = require('http').Server(app);
4const io = require('socket.io')(http);

如您所知,我们正在使用Express和Socket.IO来设置我们的服务器。IO在本地WebSocket之上提供了一层抽象层。它附带了一些很好的功能,比如针对不支持WebSocket的旧浏览器的后备机制,以及创建_Room_的能力。我们将立即看到这一点的实际效果。

为了实现我们的实时文档协作应用,我们需要一种存储文档‘的方法。在生产环境中,您可能希望使用数据库,但在本教程的范围内,我们将使用Documents`的内存存储:

1[label socket-server/src/app.js]
2const documents = {};

现在,让我们定义我们希望socket服务器实际做的事情:

1[label socket-server/src/app.js]
2io.on("connection", socket => {
3  // ...
4});

我们来分析一下。)`是一个事件监听器。第一个参数是事件的名称,第二个参数通常是在事件触发时执行的回调,带有事件负载。

我们看到的第一个示例是当客户端连接到套接字服务器时(Connection是Socket.IO中的保留事件类型)。

我们得到一个socket变量来传递给我们的回调,以启动与该一个套接字或多个套接字的通信(即,广播)。

SafeJoin

我们将设置一个本地函数(safeJoin)来处理加入和离开_rooms_:

 1[label socket-server/src/app.js]
 2io.on("connection", socket => {
 3  let previousId;
 4
 5  const safeJoin = currentId => {
 6    socket.leave(previousId);
 7    socket.join(currentId, () => console.log(`Socket ${socket.id} joined room ${currentId}`));
 8    previousId = currentId;
 9  };
10
11  // ...
12});

在这种情况下,当客户加入聊天室时,他们正在编辑特定文档。因此,如果多个客户端在同一房间中,则它们都在编辑同一文档。

从技术上讲,一个套接字可以在多个房间里,但我们不想让一个客户同时编辑多个文档,所以如果他们交换文档,我们需要离开以前的房间,加入新的房间。这个小函数可以解决这个问题。

套接字从客户端监听的事件类型有三种:

==同步,由Elderman更正==@elder_man ==同步,由Elderman更正==@elder_man ==同步,由Elderman更正==@elder_man

以及由套接字向客户端发出的两种事件类型:

  • `文档‘
  • `单据‘

Колибрипрограмма

让我们来看看第一个事件类型--getDoc

 1[label socket-server/src/app.js]
 2io.on("connection", socket => {
 3  // ...
 4
 5  socket.on("getDoc", docId => {
 6    safeJoin(docId);
 7    socket.emit("document", documents[docId]);
 8  });
 9
10  // ...
11});

当客户端发出getDoc事件时,套接字将接受负载(在我们的例子中,它只是一个id),使用该docId进入房间,并将存储的Docent发回给发起客户端。这就是socket.emit(‘文档’,...)发挥作用的地方。

addDoc

让我们来看看第二个事件类型--addDoc

 1[label socket-server/src/app.js]
 2io.on("connection", socket => {
 3  // ...
 4
 5  socket.on("addDoc", doc => {
 6    documents[doc.id] = doc;
 7    safeJoin(doc.id);
 8    io.emit("documents", Object.keys(documents));
 9    socket.emit("document", doc);
10  });
11
12  // ...
13});

addDoc事件中,负载是一个Docent对象,目前只由客户端生成的id组成。我们告诉我们的套接字加入该ID的房间,以便将来的任何编辑都可以广播给同一房间中的任何人。

接下来,我们希望连接到服务器的每个人都知道有一个新文档要处理,所以我们用io.emit('documents',...)广播给所有客户端。功能

请注意socket.emit()io.emit()之间的区别-socket版本用于只发送回初始化客户端,io版本用于发送到连接到我们服务器的每个人。

帖子主题:Re:Колибри

让我们来看看第三个事件类型--editDoc

 1[label socket-server/src/app.js]
 2io.on("connection", socket => {
 3  // ...
 4
 5  socket.on("editDoc", doc => {
 6    documents[doc.id] = doc;
 7    socket.to(doc.id).emit("document", doc);
 8  });
 9
10  // ...
11});

使用editDoc事件,有效负载将是在任何重新加载之后处于其状态的整个文档。我们将替换数据库中的现有文档,然后将新文档仅广播给当前正在查看该文档的客户端。我们通过调用socket.to(doc.id).emit(document,doc)来实现这一点,它向该特定房间中的所有套接字发出。

最后,每当建立新连接时,我们都会向所有客户端广播,以确保新连接在连接时收到最新的文档更改:

1[label socket-server/src/app.js]
2io.on("connection", socket => {
3  // ...
4
5  io.emit("documents", Object.keys(documents));
6
7  console.log(`Socket ${socket.id} has connected`);
8});

在所有套接字函数都设置好之后,选择一个端口并监听它:

1[label socket-server/src/app.js]
2http.listen(4444, () => {
3  console.log('Listening on port 4444');
4});

在您的终端中运行以下命令以启动服务器:

1node src/app.js

我们现在有了一个用于文档协作的全功能套接字服务器!

第二步-安装@Angel/cli并创建客户端App

打开一个新的终端窗口并导航到项目目录。

运行以下命令将Angular CLI安装为devDeputy

1npm install @angular/[email protected] --save-dev

现在,使用@angular/sort命令创建一个新的Angular项目,没有Angular Routing,使用SCSS进行样式化:

1ng new socket-app --routing=false --style=scss

然后,切换到服务器目录:

1cd socket-app

现在,我们将安装我们的包依赖项:

1npm install [email protected] --save

ngx-套接字-io是Socket.IO客户端库上的角度包装。

然后,使用@angular/cli命令生成一个Document模型、一个Document-list组件、一个Document组件和一个Document服务:

1ng generate class models/document --type=model
2ng generate component components/document-list
3ng generate component components/document
4ng generate service services/document

现在您已经完成了项目的设置,您可以继续为客户端编写代码了。

APP模块

打开app.modes.ts

1nano src/app/app.module.ts

并导入FormsModuleSocketioModuleSocketioConfig

1[label socket-app/src/app/app.module.ts]
2// ... other imports
3import { FormsModule } from '@angular/forms';
4import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';

在您的@NgModule声明之前,定义config

1[label socket-app/src/app/app.module.ts]
2const config: SocketIoConfig = { url: 'http://localhost:4444', options: {} };

您会注意到这是我们之前在服务器的app.js中声明的端口号。

现在,添加到你的imports数组中,所以它看起来像:

 1[label socket-app/src/app/app.module.ts]
 2@NgModule({
 3  // ...
 4  imports: [
 5    // ...
 6    FormsModule,
 7    SocketIoModule.forRoot(config)
 8  ],
 9  // ...
10})

这将在AppModule加载后立即触发到我们的套接字服务器的连接。

文档模型和文档服务

打开Document.Model.ts

1nano src/app/models/document.model.ts

并定义iddoc

1[label socket-app/src/app/models/document.model.ts]
2export class Document {
3  id: string;
4  doc: string;
5}

打开Docent.service.ts

1nano src/app/services/document.service.ts

并在类定义中添加以下内容:

 1[label socket-app/src/app/services/document.service.ts]
 2import { Injectable } from '@angular/core';
 3import { Socket } from 'ngx-socket-io';
 4import { Document } from 'src/app/models/document.model';
 5
 6@Injectable({
 7  providedIn: 'root'
 8})
 9export class DocumentService {
10  currentDocument = this.socket.fromEvent<Document>('document');
11  documents = this.socket.fromEvent<string[]>('documents');
12
13  constructor(private socket: Socket) { }
14
15  getDocument(id: string) {
16    this.socket.emit('getDoc', id);
17  }
18
19  newDocument() {
20    this.socket.emit('addDoc', { id: this.docId(), doc: '' });
21  }
22
23  editDocument(document: Document) {
24    this.socket.emit('editDoc', document);
25  }
26
27  private docId() {
28    let text = '';
29    const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
30
31    for (let i = 0; i < 5; i++) {
32      text += possible.charAt(Math.floor(Math.random() * possible.length));
33    }
34
35    return text;
36  }
37}

这里的方法分别表示套接字服务器正在侦听的三种事件类型。属性CurrentDocumentDocuments表示套接字服务器发出的事件,在客户端作为Observable消费。

您可能会注意到对this.docID()的调用。这是一个小的私有方法,它生成一个随机字符串以指定为文档ID。

文档列表组件

让我们将文档列表放在一个副标题中。目前,它只显示docId--一个随机字符串。

打开DOCUMENT-LIS.COMPOMENT.html

1nano src/app/components/document-list/document-list.component.html

并将内容替换为以下内容:

 1[label socket-app/src/app/components/document-list/document-list.component.html]
 2<div class='sidenav'>
 3    <span
 4      (click)='newDoc()'
 5    >
 6      New Document
 7    </span>
 8    <span
 9      [class.selected]='docId === currentDoc'
10      (click)='loadDoc(docId)'
11      *ngFor='let docId of documents | async'
12    >
13      {{ docId }}
14    </span>
15</div>

打开DOCUMENT-LIS.COMPOMENT.SCSS

1nano src/app/components/document-list/document-list.component.scss

并添加一些样式:

 1[label socket-app/src/app/components/document-list/document-list.component.scss]
 2.sidenav {
 3  background-color: #111111;
 4  height: 100%;
 5  left: 0;
 6  overflow-x: hidden;
 7  padding-top: 20px;
 8  position: fixed;
 9  top: 0;
10  width: 220px;
11
12  span {
13    color: #818181;
14    display: block;
15    font-family: 'Roboto', Tahoma, Geneva, Verdana, sans-serif;
16    font-size: 25px;
17    padding: 6px 8px 6px 16px;
18    text-decoration: none;
19
20    &.selected {
21      color: #e1e1e1;
22    }
23
24    &:hover {
25      color: #f1f1f1;
26      cursor: pointer;
27    }
28  }
29}

打开DOCUMENT-LIS.COMPOMENT.ts

1nano src/app/components/document-list/document-list.component.ts

并在类定义中添加以下内容:

 1[label socket-app/src/app/components/document-list/document-list.component.ts]
 2import { Component, OnInit, OnDestroy } from '@angular/core';
 3import { Observable, Subscription } from 'rxjs';
 4
 5import { DocumentService } from 'src/app/services/document.service';
 6
 7@Component({
 8  selector: 'app-document-list',
 9  templateUrl: './document-list.component.html',
10  styleUrls: ['./document-list.component.scss']
11})
12export class DocumentListComponent implements OnInit, OnDestroy {
13  documents: Observable<string[]>;
14  currentDoc: string;
15  private _docSub: Subscription;
16
17  constructor(private documentService: DocumentService) { }
18
19  ngOnInit() {
20    this.documents = this.documentService.documents;
21    this._docSub = this.documentService.currentDocument.subscribe(doc => this.currentDoc = doc.id);
22  }
23
24  ngOnDestroy() {
25    this._docSub.unsubscribe();
26  }
27
28  loadDoc(id: string) {
29    this.documentService.getDocument(id);
30  }
31
32  newDoc() {
33    this.documentService.newDocument();
34  }
35}

让我们从属性开始。Documents将是所有可用文档的流。CurrentDocId为当前选中单据的id。文档列表需要知道我们所在的文档,这样我们就可以在Sideav中突出显示该文档ID。_docSub是对Subscription的引用,该Subscription为我们提供当前或选定的文档。我们需要这个,这样我们才能在`ngOnDestroy‘生命周期方法中取消订阅。

您会注意到loadDoc()newDoc()方法不返回或赋值任何内容。请记住,这些函数向套接字服务器发送事件,套接字服务器转过身来向我们的观察点发送回一个事件。获取已有文档或添加新文档的返回值是通过上面的`Observable‘模式实现的。

文档组件

这将是文档编辑图面。

打开document.component.html

1nano src/app/components/document/document.component.html

并将内容替换为以下内容:

1[label socket-app/src/app/components/document/document.component.html]
2<textarea
3  [(ngModel)]='document.doc'
4  (keyup)='editDoc()'
5  placeholder='Start typing...'
6></textarea>

打开Docent.Component.scss

1nano src/app/components/document/document.component.scss

并在默认的HTMLtextarea上更改一些样式:

 1[label socket-app/src/app/components/document/document.component.scss]
 2textarea {
 3  border: none;
 4  font-size: 18pt;
 5  height: 100%;
 6  padding: 20px 0 20px 15px;
 7  position: fixed;
 8  resize: none;
 9  right: 0;
10  top: 0;
11  width: calc(100% - 235px);
12}

打开Document.Component.ts

1src/app/components/document/document.component.ts

并在类定义中添加以下内容:

 1[label socket-app/src/app/components/document/document.component.ts]
 2import { Component, OnInit, OnDestroy } from '@angular/core';
 3import { Subscription } from 'rxjs';
 4import { startWith } from 'rxjs/operators';
 5
 6import { Document } from 'src/app/models/document.model';
 7import { DocumentService } from 'src/app/services/document.service';
 8
 9@Component({
10  selector: 'app-document',
11  templateUrl: './document.component.html',
12  styleUrls: ['./document.component.scss']
13})
14export class DocumentComponent implements OnInit, OnDestroy {
15  document: Document;
16  private _docSub: Subscription;
17
18  constructor(private documentService: DocumentService) { }
19
20  ngOnInit() {
21    this._docSub = this.documentService.currentDocument.pipe(
22      startWith({ id: '', doc: 'Select an existing document or create a new one to get started' })
23    ).subscribe(document => this.document = document);
24  }
25
26  ngOnDestroy() {
27    this._docSub.unsubscribe();
28  }
29
30  editDoc() {
31    this.documentService.editDocument(this.document);
32  }
33}

与我们在上面的DocumentListComponent中使用的模式类似,我们将订阅当前文档的更改,并在更改当前文档时向套接字服务器触发事件。这意味着,如果其他客户端正在编辑与我们相同的文档,我们将看到所有更改,反之亦然。我们使用RxJS 'startWith'操作符在用户第一次打开应用程序时给他们一个小消息。

AppComponent

打开app.Component.html

1nano src/app.component.html

并通过将内容替换为以下内容来组合这两个自定义组件:

1[label socket-app/src/app.component.html]
2<app-document-list></app-document-list>
3<app-document></app-document>

第三步-查看正在运行的App

在我们的Socket服务器仍在终端窗口中运行的情况下,让我们打开一个新的终端窗口并启动我们的角度应用程序:

1ng serve

在不同的浏览器选项卡中打开多个http://localhost:4200实例,并查看其运行情况。

使用ANGLE和Socket.IO的实时文档协作应用

现在,您可以在两个浏览器窗口中创建新文档并查看它们的更新。您可以在一个浏览器窗口中进行更改,并在另一个浏览器窗口中看到所做的更改。

结论

在本教程中,您已经完成了使用WebSocket的初步探索。您使用它构建了一个实时文档协作应用程序。它支持多个浏览器会话以连接到服务器并更新和修改多个文档。

如果您想了解更多有关ANGLE的信息,请查看我们的ANGLE主题页面以获取练习和编程项目。

如果您想了解有关Socket.IO的更多信息,请查看集成Vue.js和Socket.IO.

其他WebSocket项目包括实时聊天应用程序。请参阅如何使用Reaction和GraphQL.构建实时聊天应用

Published At
Categories with 技术
Tagged with
comments powered by Disqus