介紹
WebSocket 是一種允許服務器和客戶端之間進行全雙工通信的互聯網協議。該協議超越了典型的 HTTP 請求和響應範式。通過 WebSocket,服務器可以向客戶端發送數據,而無需客戶端發起請求,因此可以實現一些非常有趣的應用程序。

在本教程中,您将構建一個實時文檔協作應用程序(類似于 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 — 設置項目目錄并創建 Socket 服務器
首先,打開您的終端并創建一個新的項目目錄,該目錄将包含我們的服務器和客戶端代碼:
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
現在,在 src 目錄中創建一個名爲 app.js 的新文件,并使用您喜歡的文本編輯器打開它:
nano src/app.js
從 Express 和 Socket.IO 開始編寫 app.js 文件的 require 語句:
const app = require('express')(); const http = require('http').Server(app); const io = require('socket.io')(http);
正如您所看到的,我們使用 Express 和 Socket.IO 來設置我們的服務器。Socket.IO 提供了對原生 WebSocket 的抽象層。它帶有一些很好的功能,例如對不支持 WebSocket 的舊版浏覽器的回退機制,以及創建“房間”的能力。我們将在下一步中看到這一點。
對于我們的實時文檔協作應用程序,我們将需要一種存儲 documents 的方式。在生産環境中,您可能希望使用數據庫,但在本教程的範圍内,我們将使用一個存儲 documents 的内存存儲:
const documents = {};
現在,讓我們定義我們希望我們的 socket 服務器實際執行的操作:
io.on("connection", socket => { // ... });
讓我們來分解一下。.on('...') 是一個事件監聽器。第一個參數是事件的名稱,第二個參數通常是在事件觸發時執行的回調函數,帶有事件負載。
我們首先看到的示例是當客戶端連接到 socket 服務器時(connection 是 Socket.IO 中的保留事件類型)。
我們獲得一個 socket 變量,以便将其傳遞給我們的回調函數,以便與該 socket 或多個 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 可以在多個房間中,但我們不希望讓一個客戶端同時編輯多個文檔,因此如果他們切換文檔,我們需要離開先前的房間并加入新的房間。這個小函數負責處理這個問題。
我們的 socket 正在監聽來自客戶端的三種事件類型:
- getDoc
- addDoc
- editDoc
以及從我們的 socket 發出的兩種事件類型:
- document
- documents
getDoc
讓我們來處理第一種事件類型 - getDoc:
io.on("connection", socket => { // ... socket.on("getDoc", docId => { safeJoin(docId); socket.emit("document", documents[docId]); }); // ... });
當客戶端發出 getDoc 事件時,socket 将獲取負載(在我們的情況下,它隻是一個 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。我們告訴我們的 socket 加入該 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) 來實現這一點,該方法會向該特定房間中的所有 socket 發出。
最後,每當建立新連接時,我們向所有客戶端廣播,以确保新連接在連接時接收到最新的文檔更改:
io.on("connection", socket => { // ... io.emit("documents", Object.keys(documents)); console.log(`Socket ${socket.id} has connected`); });
在設置好 socket 函數之後,選擇一個端口并在其上進行監聽:
http.listen(4444, () => { console.log('Listening on port 4444'); });
在您的終端中運行以下命令以啓動服務器:
node src/app.js
現在,我們已經擁有了一個完全功能的用于文檔協作的 socket 服務器!
步驟 2 — 安裝 @angular/cli 并創建客戶端應用
打開一個新的終端窗口并導航到項目目錄。
運行以下命令将 Angular CLI 安裝爲 devDependency:
npm install @angular/cli@10.0.4 --save-dev
現在,使用 @angular/cli 命令創建一個新的 Angular 項目,不使用 Angular 路由,并使用 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:
// ... 其他導入 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 加載時觸發與我們的 socket 服務器的連接。
Document 模型和 Document 服務
打開 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'); documents = this.socket.fromEvent('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
這裏的方法代表了 socket 服務器正在監聽的三種事件類型的每個發射。currentDocument 和 documents 屬性代表了 socket 服務器發射的事件,在客戶端作爲 Observable 進行消費。您可能會注意到對 this.docId() 的調用。這是一個小的私有方法,用于生成一個随機字符串,分配爲文檔 id。
Document 列表組件
讓我們将文檔列表放在一個側邊欄中。目前,它隻顯示 docId - 一串随機字符。
打開 document-list.component.html:
nano src/app/components/document-list/document-list.component.html
并用以下内容替換其中的内容:
New Document {{ docId }}打開 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; 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。文檔列表需要知道我們在哪個文檔上,以便我們可以在側邊欄中突出顯示該文檔 id。_docSub 是給出當前或選定文檔的 Subscription 的引用。我們需要這個引用,這樣我們就可以在 ngOnDestroy 生命周期方法中取消訂閱。
您會注意到 loadDoc() 和 newDoc() 方法沒有返回或分配任何内容。請記住,這些方法觸發了 socket 服務器的事件,然後 socket 服務器會向我們的 Observables 發出事件。從上面的 Observable 模式中實現了獲取現有文檔或添加新文檔的返回值。
文檔組件
這将是文檔編輯界面。
打開 document.component.html:
nano src/app/components/document/document.component.html
并用以下内容替換其中的内容:
打開 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: '選擇一個現有文檔或創建一個新文檔以開始' }) ).subscribe(document => this.document = document); } ngOnDestroy() { this._docSub.unsubscribe(); } editDoc() { this.documentService.editDocument(this.document); } }
與上面的 DocumentListComponent 中使用的模式類似,我們将訂閱當前文檔的更改,并在我們更改當前文檔時向套接字服務器發送事件。這意味着如果任何其他客戶端正在編輯我們正在編輯的相同文檔,我們将看到所有更改,反之亦然。我們使用 RxJS 的 startWith 操作符在用戶首次打開應用時提供一條小消息。
AppComponent
打開 app.component.html:
nano src/app.component.html
并通過以下内容替換其中的内容來組合兩個自定義組件:
步驟 3 —— 查看應用程序的運行情況
在我們的套接字服務器仍在一個終端窗口中運行的情況下,讓我們打開一個新的終端窗口并啓動我們的 Angular 應用程序:
ng serve
在單獨的浏覽器标簽中打開多個 http://localhost:4200 實例并查看其運行情況。
!使用 Angular 和 Socket.IO 構建的實時文檔協作應用程序
現在,您可以創建新文檔并在兩個浏覽器窗口中看到它們更新。您可以在一個浏覽器窗口中進行更改,并在另一個浏覽器窗口中看到更改的反映。
結論
在本教程中,您已經完成了對使用 WebSocket 的初步探索。您使用它構建了一個實時文檔協作應用程序。它支持多個浏覽器會話連接到服務器,并更新和修改多個文檔。