前端React、后端NestJs 基于Socket.io开发WebSocket应用

1,056 阅读6分钟

HTTP 协议的通信只能由客户端发起,要实现实时双向数据通信,一般情况下会使用WebSocketwebsoket 是一种 clientservice 保存长链接通讯的一种技术。在nestjs中有两个现成支持的 WebSocket 平台:socket.iows,它们都是基于websocket封装的框架。nestjs 默认支持socket.io,而如果使用ws则需要切换适配器。

socket.io在websocket的基础上集成了丰富强大的功能,优化了我们构建复杂应用的过程。

下面我们使用nestjsreact基于socket.io来一起开发WebSocket应用。

初始化nestjs项目

全局安装nestjs脚手架@nestjs/cli

npm install -g @nestjs/cli

创建一个nest项目

nest new nest_websocket

:Users:chenkai:Library:Application Support:typora-user-images:image-20240813174720181.png

安装依赖

pnpm i @nestjs/websockets @nestjs/platform-socket.io socket.io

开发

创建个 websocket 模块,新建两个文件

:Users:chenkai:Library:Application Support:typora-user-images:image-20240813183014649.png

// web-socket.gateway.ts 文件
import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
} from '@nestjs/websockets';

@WebSocketGateway()
export class WSGateway {
  constructor() {}

  @SubscribeMessage('sendMessage')
  sendMessage(@MessageBody() data: { message: string }) {
    return data;
  }
}

Nest中的网关是一个用 @WebSocketGateway() 装饰器注释的类。

创建网关后,在模块中注册它。

// web-socket.module.ts 文件
import { Module } from '@nestjs/common';
import { WSGateway } from './web-socket.gateway';

@Module({
  providers: [WSGateway],
})
export class WebSocketModule {}

app.module 中引入

// app.module.ts 文件
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { WebSocketModule } from './web-socket/web-socket.module';

@Module({
  imports: [WebSocketModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}
// main.ts 文件
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { NestExpressApplication } from '@nestjs/platform-express';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  await app.listen(3000);
}
bootstrap();

客户端

然后用react写一下客户端代码

安装依赖

socket.io-clientsocket.i专为前端设计的库

pnpm i socket.io-client

书写组件

import io from "socket.io-client";
const socket = io("ws://localhost:3000");
socket.on("connect", function () {
  console.log("连接成功");
  socket.emit(
    "sendMessage",
    {
      message: "Hello world",
    },
    (data) => {
      console.log(data);
    }
  );
});
socket.on("disconnect", function () {
  console.log("断开连接");
});
export default function Demo() {
  return <></>;
}

前后端联调

把服务跑起来

pnpm start

前端项目也跑起来

pnpm dev

浏览器访问前端地址 http ://localhost:5173

:Users:chenkai:Library:Application Support:typora-user-images:image-20240814165818043.png

这时候发现出现跨域问题,怎么解决呢

@WebSocketGateway()可以通过传参将受支持的配置选项传递给装饰器

配置一下跨域问题,如下代码

// web-socket.gateway.ts 文件
import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
} from '@nestjs/websockets';

@WebSocketGateway({
  cors: {
    origin: '*', 
  }, // 配置一下跨域问题
})
export class WSGateway {
  constructor() {}

  @SubscribeMessage('sendMessage')
  sendMessage(@MessageBody() data: { message: string }) {
    return data;
  }
}

结果如下,连接成功,

:Users:chenkai:Library:Application Support:typora-user-images:image-20240814173128992.png

再捋一下流程

nestJs中通过@SubscribeMessage()装饰器订阅传入消息,如下方代码,它将订阅“sendMessage”消息,并收到的数据返回。

	@SubscribeMessage('sendMessage')
  sendMessage(@MessageBody() data: { message: string }) {
    return data;
  }

客户端中, 通过订阅connect,在连接成功后,客户端触发“sendMessage”消息,发送数据,然后获取到服务端返回的消息并打印

import io from "socket.io-client";
const socket = io("ws://localhost:3000");
socket.on("connect", function () {
  console.log("连接成功");
  socket.emit(
    "sendMessage",
    {
      message: "Hello world",
    },
    (data) => {
      console.log(data);
    }
  );
});
socket.on("disconnect", function () {
  console.log("断开连接");
});

一般来说,每个网关都会监听与 HTTP 服务器相同的端口,也可以手动修改端口,通过将参数传递给 @WebSocketGateway(80) 装饰器来修改此默认行为,其中 80 是选定的端口号。

例如我们可以修改一下端口号(如下图改成3001),这时候前端也需要修改为同样的端口地址。

import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
} from '@nestjs/websockets';

@WebSocketGateway(3001, { // 修改端口号为3001
  cors: {
    origin: '*',
  }, // 配置一下跨域问题
})
export class WSGateway {
  constructor() {}

  @SubscribeMessage('sendMessage')
  sendMessage(@MessageBody() data: { message: string }) {
    return data;
  }
}

上面代码中,服务端订阅了“sendMessage”消息,并在接收到消息后返回数据。

	@SubscribeMessage('sendMessage')
  sendMessage(@MessageBody() data: { message: string }) {
    return data;
  }

上面代码可以修改为返回一个包含eventdata两个属性的对象

	@SubscribeMessage('sendMessage')
  sendMessage(@MessageBody() data: { message: string }) {
    return {
      event: 'myEvent',
      data: data,
    };
  }

修改后,原先的前端代码中,回调函数不会触发了。

  socket.emit(
    "sendMessage",
    {
      message: "Hello world",
    },
    (data) => {
      console.log(data);
    }
  );

前端代码需要增加订阅“myEvent”事件,这样就能获取到后端返回的数据

socket.on("myEvent", function (data) {
  console.log(data);
});

我们也可以注入实例使用 socket.io 的api,通过使用 @ConnectedSocket() 装饰器。

import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';
import { Server } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: '*',
  }, // 配置一下跨域问题
})
export class WSGateway {
  constructor() {}

  @SubscribeMessage('sendMessage')
  sendMessage(
    @MessageBody() data: { message: string },
    @ConnectedSocket() server: Server,
  ) {
    server.emit('myEvent', { message: data.message });
    return;
  }
}

前端还是订阅“myEvent”事件获取数据打印

:Users:chenkai:Library:Application Support:typora-user-images:image-20240819161626683.png

也可以用@WebSocketServer 注入实例

import {
  WebSocketGateway,
  SubscribeMessage,
  MessageBody,
  WebSocketServer,
} from '@nestjs/websockets';
import { Server } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: '*',
  }, // 配置一下跨域问题
})
export class WSGateway {
  constructor() {}

  @WebSocketServer()
  server: Server;

  @SubscribeMessage('sendMessage')
  sendMessage(@MessageBody() data: { message: string }) {
    this.server.emit('myEvent', { message: data.message });
    return;
  }
}

结果也是一样的。

简单例子

通过上面的示例,我们可以做一个简单的聊天室

服务端代码,使用OnGatewayConnection, OnGatewayDisconnect生命周期钩子。

  • OnGatewayConnection强制实现 handleConnection() 方法,将特定于库的客户端实例作为参数。

  • OnGatewayDisconnect强制实现 handleDisconnect() 方法,将特定于库的客户端实例作为参数。

服务端订阅“userMessage”消息接收数据,并通过“chatMessage”事件向前端发送数据。

import {
  WebSocketGateway,
  SubscribeMessage,
  WebSocketServer,
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: '*',
  },
})
export class WSGateway implements OnGatewayConnection, OnGatewayDisconnect {
  constructor() {}

  @WebSocketServer()
  server: Server;

  handleConnection(client: Socket) {
    this.server.emit('chatMessage', {
      message: client.id + ' 上线了',
    });
  }

  handleDisconnect(client: Socket) {
    this.server.emit('chatMessage', {
      message: client.id + ' 离线了',
    });
  }

  @SubscribeMessage('userMessage')
  sendMessage(@MessageBody() data: { id: string; message: string }) {
    this.server.emit('chatMessage', {
      message: data.id + ': ' + data.message,
    });
  }
}

前端代码

import { useState, useRef } from "react";
import io, { Socket } from "socket.io-client";
import "./index.scss";
export default function Demo() {
  const [inputValue, setInputValue] = useState<string>("");
  const [messageList, setMessageList] = useState<{ message: string }[]>([]);
  const socketRef = useRef<Socket | null>(null);

  function connect() {
    socketRef.current = io("ws://localhost:3000");
    socketRef.current.on("connect", function () {
      socketRef.current!.on(
        "chatMessage",
        function (data: { message: string }) {
          setMessageList((messageList) => [
            ...messageList,
            { message: data.message },
          ]);
        }
      );
    });
  }

  function disconnect() {
    if (socketRef.current) {
      socketRef.current.disconnect();
    }
  }

  const sendMessage = () => {
    if (socketRef.current) {
      socketRef.current.emit("userMessage", {
        id: socketRef.current.id,
        message: inputValue,
      });
      setInputValue("");
    }
  };

  return (
    <div className="page-content">
      <div className="message-content">
        {messageList.map((e) => (
          <p className="message-item" key={e.message}>
            {e.message}
          </p>
        ))}
        <button className="button" onClick={connect}>
          上线
        </button>
        <button className="button" onClick={disconnect}>
          下线
        </button>
      </div>
      <div className="input-wrap">
        <input
          type="text"
          className="input"
          value={inputValue}
          onChange={(event) => setInputValue(event.target.value)}
        />
        <button className="button" onClick={sendMessage}>
          发送
        </button>
      </div>
    </div>
  );
}

效果如下

CleanShot 2024-08-20 at 16.11.59.gif

房间

socket.io 有 room 功能。sockets可以joinleave房间。它可用于向一部分客户端广播事件。

加入

可以调用join以将socket订阅到给定的频道(加入对应的房间)

io.on("connection", (socket) => {
  socket.join("some room");
});

离开

离开房间通过调用leave

socket.leave("some room");

向房间发送消息

使用toin向特定的房间发消息

io.to("some room").emit("some event");
io.in("some room").emit("some event");

也可以同时发射到多个房间

io.to("room1").to("room2").to("room3").emit("some event");

在这种情况下,将执行“联合”:至少在其中一个房间中的每个socket都将获得一次事件(即使socket在两个或更多房间中)。

使用房间做个聊天功能

利用socket.io 的 room 功能简单做一个聊天的功能

服务端代码如下

import {
  WebSocketGateway,
  SubscribeMessage,
  WebSocketServer,
  MessageBody,
  ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway({
  cors: {
    origin: '*',
  },
})
export class RoomWSGateway {
  constructor() {}

  @WebSocketServer()
  server: Server;

  @SubscribeMessage('joinRoom')
  joinRoom(
    @MessageBody() data: { room: string },
    @ConnectedSocket() client: Socket,
  ) {
    client.join(data.room);
    this.server.to(data.room).emit('roomMessage', {
      message: '欢迎' + client.id + '用户加入' + data.room,
    });
  }

  @SubscribeMessage('leaveRoom')
  leaveRoom(
    @MessageBody() data: { room: string },
    @ConnectedSocket() client: Socket,
  ) {
    client.leave(data.room);
    this.server
      .to(data.room)
      .emit('roomMessage', { message: client.id + '用户离开了' + data.room });
  }

  @SubscribeMessage('sendMessage')
  sendMessage(
    @MessageBody() data: { room: string; id: string; message: string },
  ) {
    this.server.to(data.room).emit('roomMessage', {
      message: data.id + ': ' + data.message,
    });
  }
}

前端代码

import { useState, useRef } from "react";
import io, { Socket } from "socket.io-client";
import "./index.scss";
export default function Room() {
  const [inputValue, setInputValue] = useState<string>("");
  const [messageList, setMessageList] = useState<{ message: string }[]>([]);
  const socketRef = useRef<Socket | null>(null);
  const room = useRef<string>("");

  function joinRoom(roomName: string) {
    socketRef.current = io("ws://localhost:3000");
    socketRef.current.on("connect", function () {
      if (socketRef.current) {
        socketRef.current.emit("joinRoom", { room: roomName });
        room.current = roomName;
        socketRef.current.on("roomMessage", (data) => {
          setMessageList((messageList) => [
            ...messageList,
            { message: data.message },
          ]);
        });
      }
    });
  }

  function disconnect() {
    if (socketRef.current) {
      socketRef.current.emit("leaveRoom", { room: room.current });
    }
  }

  const sendMessage = () => {
    if (socketRef.current) {
      socketRef.current.emit("sendMessage", {
        room: room.current,
        id: socketRef.current.id,
        message: inputValue,
      });
      setInputValue("");
    }
  };

  return (
    <div className="page-content">
      <div className="message-content">
        {messageList.map((e) => (
          <p className="message-item" key={e.message}>
            {e.message}
          </p>
        ))}
        <button className="button" onClick={() => joinRoom("roomA")}>
          加入房间A
        </button>
        <button className="button" onClick={() => joinRoom("roomB")}>
          加入房间B
        </button>
        <button className="button" onClick={disconnect}>
          离开房间
        </button>
      </div>
      <div className="input-wrap">
        <input
          type="text"
          className="input"
          value={inputValue}
          onChange={(event) => setInputValue(event.target.value)}
        />
        <button className="button" onClick={sendMessage}>
          发送
        </button>
      </div>
    </div>
  );
}

如此,同一个房间的人可以收到房间内人发的消息,而无法收到其他不同的房间的消息。

CleanShot 2024-08-21 at 14.29.52.gif

代码

github.com/chenkai77/n…