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

介绍

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

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

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

先决条件

要完成本教程,您需要:

本教程最初是在由 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.jssrc目录中创建一个名为的新文件,并使用您喜欢的文本编辑器打开它:

  • nano src/app.js

requireExpress 和 Socket.IO语句开始

套接字服务器/src/app.js
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

套接字服务器/src/app.js
const documents = {};

现在,让我们定义我们希望套接字服务器实际执行的操作:

套接字服务器/src/app.js
io.on("connection", socket => {
  // ...
});

让我们分解一下。.on('...')是一个事件监听器。第一个参数是事件的名称,第二个参数通常是在事件触发时执行的回调,以及事件负载。

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

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

safeJoin

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

套接字服务器/src/app.js
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

套接字服务器/src/app.js
io.on("connection", socket => {
  // ...

  socket.on("getDoc", docId => {
    safeJoin(docId);
    socket.emit("document", documents[docId]);
  });

  // ...
});

当客户端发出getDoc事件时,套接字将获取有效负载(在我们的例子中,它只是一个 id),加入一个房间docId,并将存储的document仅发送回发起客户端。这就是socket.emit('document', ...)发挥作用的地方

addDoc

让我们处理第二种事件类型 – addDoc

套接字服务器/src/app.js
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

套接字服务器/src/app.js
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)做到这一点,它向该特定房间中的所有套接字发出信号。

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

套接字服务器/src/app.js
io.on("connection", socket => {
  // ...

  io.emit("documents", Object.keys(documents));

  console.log(`Socket ${socket.id} has connected`);
});

套接字功能都设置好后,选择一个端口并监听它:

套接字服务器/src/app.js
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

socket-app/src/app/app.module.ts
// ... other imports
import { FormsModule } from '@angular/forms';
import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';

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

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

您会注意到这是我们之前在服务器的app.js.

现在,添加到您的imports数组中,看起来像:

socket-app/src/app/app.module.ts
@NgModule({
  // ...
  imports: [
    // ...
    FormsModule,
    SocketIoModule.forRoot(config)
  ],
  // ...
})

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

文档模型和文档服务

打开document.model.ts:

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

并定义iddoc

socket-app/src/app/models/document.model.ts
export class Document {
  id: string;
  doc: string;
}

打开document.service.ts:

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

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

socket-app/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;
  }
}

此处的方法分别表示套接字服务器正在侦听的三种事件类型。属性currentDocumentdocuments表示套接字服务器发出的事件,在客户端上作为Observable.

您可能会注意到对 的调用this.docId()这是一个小的私有方法,它生成一个随机字符串作为文档 id 分配。

文档列表组件

让我们将文档列表放在 sidenav 中。现在,它只显示docId– 一个随机字符串。

打开document-list.component.html:

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

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

socket-app/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

并添加一些样式:

socket-app/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

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

socket-app/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

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

socket-app/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

socket-app/src/app/components/document/document.component.scss
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

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

socket-app/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

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

socket-app/src/app.component.html
<app-document-list></app-document-list>
<app-document></app-document>

第 3 步 — 查看应用程序运行情况

我们的套接字服务器仍在终端窗口中运行,让我们打开一个新的终端窗口并启动我们的 Angular 应用程序:

  • ng serve

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

带有 Angular 和 Socket.IO 的实时文档协作应用程序

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

结论

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

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

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

更多的 WebSocket 项目包括实时聊天应用程序。请参阅如何使用 React 和 GraphQL 构建实时聊天应用程序

觉得文章有用?

点个广告表达一下你的爱意吧 !😁