后端代码编写

node环境初始化

新建一个空文件夹node,初始化node环境

1
npm init -y

修改 packages.json,只保留 scripts 和 dependencies

1
2
3
4
5
6
{
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {},
}

ts环境初始化

1
tsc --init

如果没有tsc命令,需要提前在全局安装 typescript

1
npm install typescript ts-node -g

已经安装过的可忽略

检查ts是否安装成功

1
tsc -v

image-20240927113758648

执行完 tsc --init 会自动生成 tsconfig.json,修改里面的三个配置

1
2
3
"experimentalDecorators": true,                   
"emitDecoratorMetadata": true,
"strict": false,
  1. “experimentalDecorators”: true
    • 含义: 启用对装饰器的实验性支持。装饰器是一种特殊类型的声明,它能够被附加到类声明、方法、访问符、属性或参数上。装饰器在 TypeScript 中是实验性的特性,这意味着它们可能在未来的版本中发生变化。
    • 用途: 如果你使用装饰器(例如 Angular 的装饰器),你需要启用这个选项。
  2. “emitDecoratorMetadata”: true
    • 含义: 启用装饰器元数据的生成。装饰器元数据是编译器在装饰器上附加的额外信息,这些信息可以在运行时通过反射 API 访问。
    • 用途: 如果你需要使用反射 API 来访问装饰器元数据,你需要启用这个选项。
  3. “strict”: false
    • 含义: 禁用所有严格类型检查选项。严格模式会启用一系列额外的类型检查规则,这些规则有助于捕获潜在的错误,但有时也会导致一些合法的代码无法通过检查。

依赖安装

在 package.json 添加如下依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"scripts": {
"start": "ts-node ./index.ts"
},
"dependencies": {
"@prisma/client": "^5.19.1",
"@types/express": "^4.17.21",
"@types/node": "^22.5.5",
"axios": "^1.7.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"express": "^4.21.0",
"inversify": "^6.0.2",
"inversify-express-utils": "^6.4.6",
"reflect-metadata": "^0.2.2",
"socket.io": "^4.8.0",
"socket.io-client": "^4.8.0",
"ts-node": "^10.9.2"
}
}

然后执行 npm install 安装

引入Prisma

首先需要全局安装 prisma

1
npm install -g prisma

Prisma 官网 https://www.prisma.io/docs

然后再项目根目录执行下面的命令初始化一个基于mysql的项目

1
prisma init --datasource-provider mysql

此时的项目目录结构如下

image-20241008132442011

在 prisma/schema.prisma 文件中编写表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}

// 用户表
model User {
id Int @id @default(autoincrement())
userName String
}

// 消息表
model Message {
id Int @id @default(autoincrement())
text String
userId Int
createTime DateTime @default(now())
userName String
}

然后修改 .env 文件的数据库连接地址

image-20241008132827571

1
DATABASE_URL="mysql://root:abc123@localhost:3306/hichat"

然后执行下面命令执行SQL语句创建表

1
prisma migrate dev

执行完成后,打开数据库就会发现自动帮我们创建好了表

image-20241008133112553

编写DB

db/index.ts

用于全局共用一个PrismaClient实例

1
2
3
4
5
6
7
8
9
10
11
12
import { injectable, inject } from "inversify";
import { PrismaClient } from "@prisma/client";

@injectable()
export class PrismaDB {
prisma: PrismaClient;

// 自动注入
constructor(@inject("PrismaClient") prisma: () => PrismaClient) {
this.prisma = prisma();
}
}

统一结果返回类

utils/Result.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
export default class Result<T> {
code: number | 200;
data: T | null;
msg: string;

constructor(code: number | 200, data: T | null, msg?: string) {
this.code = code;
this.data = data;
this.msg = msg;
}

static ok<T>(data?: T | null): Result<T> {
return new Result<T>(200, data, "成功");
}

static err<T>(data?: T | null): Result<T> {
return new Result<T>(500, data, "失败");
}

public setCode(code: number | 200) {
this.code = code;
return this;
}

public setData(data: T | null) {
this.data = data;
return this;
}

public setMsg(msg: string) {
this.msg = msg;
return this;
}
}

User模块业务实现

service层

src/user/service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import {injectable,inject} from "inversify"
import { PrismaDB } from "../../db"

@injectable()
export class UserService {
// 自动注入prisma
constructor(@inject(PrismaDB) private PrismaDB: PrismaDB) {}

/**
* 注册新用户
*/
public async add(userName:string) {
return await this.PrismaDB.prisma.user.create({
data:{
userName:userName
}
})
}

/**
* 根据userName查询用户
*/
public async getUserByUserName(userName:string) {
return await this.PrismaDB.prisma.user.findFirst({
where:{
userName:userName
}
})
}

/**
* 查询用户数量
*/
public async getUserCount() {
return await this.PrismaDB.prisma.user.count()
}
}

controller层

src/user/controller.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { inject } from "inversify";
import { controller, httpGet, httpPost } from "inversify-express-utils";
import { UserService } from "./service";
import { Request, Response } from "express";
import Result from "../../utils/Result";

@controller("/user")
export class UserController {
constructor(@inject(UserService) private readonly server: UserService) {}

/**
* 添加用户
* @param req
* @param res
*/
@httpPost("/add")
public async addUser(req: Request, res: Response) {
let { userName } = req.body;
let result = await this.server.add(userName);
res.send(Result.ok(result));
}

/**
* 根据用户名获取用户
*/
@httpGet("/getUser")
public async getUser(req: Request, res: Response) {
let { userName } = req.query;
let result = await this.server.getUserByUserName(userName as string);
res.send(Result.ok(result));
}

/**
* 查询用户数量
*/
@httpGet("/getUserCount")
public async getUserCount(req: Request, res: Response) {
let result = await this.server.getUserCount();
res.send(Result.ok(result));
}
}

Message模块业务实现

dto层

src/message/message.dto.ts

1
2
3
4
5
6
7
export class MessageDto {
id?: number;
text: string;
userId: number;
userName: string;
createTime?: Date;
}

server层

src/message/service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { injectable, inject } from "inversify";
import { PrismaDB } from "../../db";
import { MessageDto } from "./message.dto";

@injectable()
export class MessageService {
// 自动注入prisma
constructor(@inject(PrismaDB) private PrismaDB: PrismaDB) {}

/**
* 添加消息
*/
public async send(message: MessageDto) {
return await this.PrismaDB.prisma.message.create({
data: {
text: message.text,
userId: message.userId,
userName: message.userName,
},
});
}

/**
* 获取消息列表
*/
public async list() {
return await this.PrismaDB.prisma.message.findMany();
}
}

controller层

src/message/controller.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { inject } from "inversify";
import { controller, httpGet, httpPost } from "inversify-express-utils";
import { Request, Response } from "express";
import Result from "../../utils/Result";
import { MessageService } from "./service";
import { MessageDto } from "./message.dto";

@controller("/message")
export class MessageController {
constructor(@inject(MessageService) private server: MessageService) {}

@httpPost("/send")
async send(req: Request, res: Response) {
const message = req.body as MessageDto;
const result = await this.server.send(message);
res.json(Result.ok(result));
}

@httpGet("/list")
async list(req: Request, res: Response) {
const result = await this.server.list();
res.json(Result.ok(result));
}
}

socket消息处理

src/message/sockit.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import { Server } from "socket.io";
import axios from "axios";
import { createServer } from "http";

// 基础URL
axios.defaults.baseURL = "http://localhost:3000";
// 响应拦截器
axios.interceptors.response.use((res) => {
return res.data;
});

// 创建一个通信服务器
const httpServer = createServer();
const io = new Server(httpServer, {
cors: {
origin: "*", // 允许跨域的前端域名
methods: ["GET", "POST"], // 允许的跨域请求方法
credentials: true, // 允许cookies等认证信息一起跨域传递
},
});

// 维护一个map,key表示房间号,value表示用户列表,value是一个数组,里面存有socketId,用户ID
const groupList = {};

// 监听连接
io.on("connection", (socket) => {
// 监听连接事件
socket.on("join", async ({ roomId, id, userName }) => {
console.log(userName, "连接到房间");
// 连接到指定的房间号
socket.join(roomId);
// 维护map信息
if (groupList[roomId]) {
groupList[roomId].push({
id, // 用户ID
userName, // 用户名
roomId, // 房间号
socketId: socket.id, // socketId
});
} else {
groupList[roomId] = [{ id, userName, roomId, socketId: socket.id }];
}
// 获取群聊总用户
let countRes = await axios.get(`/user/getUserCount`);
// 向房间内的所有用户广播消息,更新用户数量
io.to(roomId).emit("userCount", {
all: countRes.data,
online: io.engine.clientsCount, // 获取当前房间的有效连接数
});
});

// 监听发送消息事件
socket.on("send", async (data) => {
console.log(data, "接收到消息");

// 发送消息
let res = await axios.post(`/message/send`, data);
// 更新消息
io.to(data.roomId).emit("message", res.data);
});

// 监听用户离开事件
socket.on("disconnect", async () => {
// 遍历房间信息,找到是谁离开了,然后更新这个房间的在线人数
for (let key in groupList) {
let list = groupList[key];
// 根据socketId找到用户
let index = list.findIndex((item) => item.socketId === socket.id);
if (index !== -1) {
let userInfo = list[index];
let countRes = await axios.get(`/user/getUserCount`);
// 向房间内的所有用户广播消息,更新用户数量
io.to(userInfo.roomId).emit("userCount", {
all: countRes.data,
online: io.engine.clientsCount,
});
// 删除用户
list.splice(index, 1);
break;
}
}
});
});

httpServer.listen(3001, () => {
console.log("sockit服务器已启动 ws://localhost:3001");
});

业务代码整合

index.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import "reflect-metadata"; // 装饰器的基础,放在顶层
import "./src/message/sockit";
import { InversifyExpressServer } from "inversify-express-utils";
import { Container } from "inversify";
import express from "express";
import { PrismaClient } from "@prisma/client";
import { PrismaDB } from "./db";
import { UserController } from "./src/user/controller";
import { UserService } from "./src/user/service";
import { MessageController } from "./src/message/controller";
import { MessageService } from "./src/message/service";

const container = new Container();
// 注入工厂注入PrismaClient
container.bind<PrismaClient>("PrismaClient").toFactory(() => {
return () => new PrismaClient();
});
// 注入数据库ORM框架PrismaClient
container.bind(PrismaDB).to(PrismaDB);
// 用户模块
container.bind(UserController).to(UserController);
container.bind(UserService).to(UserService);
// 消息模块
container.bind(MessageController).to(MessageController);
container.bind(MessageService).to(MessageService);

const server = new InversifyExpressServer(container);

server.setConfig((app) => {
// 配置中间件,允许post参数
app.use(express.json());
// 允许跨域
app.use(function (req, res, next) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
res.setHeader("Access-Control-Allow-Methods", "*");
next();
});
});

const app = server.build();

app.listen(3000, () => {
console.log("Server is running on port http://localhost:3000");
});

运行 npm run start

image-20241008142624829

前端代码实现

初始化项目

1
npm create vue@latest

image-20241008143042164

安装依赖

1
2
3
4
5
"dependencies": {
"axios": "^1.7.7",
"sass": "^1.79.3",
"socket.io-client": "^4.8.0"
},

加入群聊

src/views/join.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
<template>
<div class="content">
<div class="logo">
<img src="../assets/logo.webp" />
</div>
<div class="main">
<div>请输入您的用户名</div>
<input
id="userName"
type="text"
placeholder="请输入内容"
autocomplete="off"
v-model="userName"
/>
</div>
<div class="btn">
<button @click="join">加入群聊</button>
</div>
</div>
</template>

<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import http from "../../utils/http";

const router = useRouter();
const userName = ref("");

async function join() {
if (!userName.value) {
alert("请输入用户名");
return;
}
let userInfo = {};
let res = await http.get(`/user/getUser?userName=${userName.value}`);
if (res.data) {
userInfo = res.data;
} else {
let res = await http.post(`/user/add`, {
userName: userName.value,
});
userInfo = res.data;
}

// 进入聊天页面
router.push({ path: "/chat", query: userInfo });
}
</script>

<style scoped lang="scss">
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;

.logo {
width: 100px;
height: 100px;
margin-top: 30%;
img {
width: 100%;
height: 100%;
}
}

.main {
width: 80%;
margin-top: 20%;
input {
padding: 10px;
box-sizing: border-box;
border: 1px solid #a18cd1;
border-radius: 5px;
width: 100%;
margin-top: 10px;
}
}

.btn {
width: 80%;
button {
padding: 10px;
border: none;
border-radius: 5px;
color: #ffffff;
margin-top: 30px;
width: 100%;
background-image: linear-gradient(-90deg, #a18cd1 0%, #fbc2eb 100%);
}
}
}
</style>
image-20241008143924321

点击加入群聊后会先去查询当前用户名是否在表中存在,如果不存在,则会新建一个用户保存到表中,并返回用户信息,如果存在,则直接返回用户信息跳转到聊天界面

群聊实现

src/views/chat.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
<template>
<div class="content">
<div class="header">
<div>聊天室({{ state.count.all }})</div>
<div class="online">当前在线人数:{{ state.count.online }}</div>
</div>
<div class="main">
<div v-for="item in state.msgList" :key="item.id">
<div class="item mymsg" v-if="item.userId === state.userInfo.id">
<div>{{ item.userName }}</div>
<div class="msg">{{ item.text }}</div>
</div>
<div class="item" v-else>
<div>{{ item.userName }}</div>
<div class="msg">{{ item.text }}</div>
</div>
</div>
</div>
<div class="footer">
<input
v-on:keyup.enter="sendMsg"
type="text"
placeholder="请输入内容"
v-model="state.msg"
/>
<button @click="sendMsg">发送</button>
</div>
</div>
</template>

<script setup>
import { nextTick, onMounted, reactive, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import io from "socket.io-client";
import http from "../../utils/http";

// 连接到后端
const socket = io("ws://localhost:3001", {
transports: ["websocket", "polling"],
withCredentials: true,
});

const route = useRoute();
const state = reactive({
msg: "",
msgList: [],
count: {
all: 0,
online: 0,
},
userInfo: {
id: parseInt(route.query.id),
userName: route.query.userName,
roomId: 1, // 房间号暂时固定为1
},
});

function sendMsg() {
let sendData = {
roomId: state.userInfo.roomId,
userId: state.userInfo.id,
userName: state.userInfo.userName,
text: state.msg,
};

state.msg = "";

// 发送消息
socket.emit("send", sendData);
}

// 获取历史消息
async function getHistoryMsg() {
const res = await http.get("/message/list");
state.msgList = res.data;
}

onMounted(() => {
// 监听连接
socket.on("connect", async () => {
// 获取历史消息
await getHistoryMsg();
// main 滑动到底部
nextTick(() => {
const main = document.querySelector(".main");
main.scrollTop = main.scrollHeight;
});
// 连接到房间
socket.emit("join", state.userInfo);
// 更新数量
socket.on("userCount", (count) => {
state.count = count;
});
// 监听消息
socket.on("message", (data) => {
// 将消息添加到列表
state.msgList.push(data);
// main 滑动到底部
nextTick(() => {
const main = document.querySelector(".main");
main.scrollTop = main.scrollHeight;
});
});
});
});

// 组件销毁时关闭连接
onUnmounted(() => {
socket.close();
});
</script>

<style scoped lang="scss">
.content {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
height: 100%;
}
.header {
height: 45px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
.online {
font-size: 12px;
color: #999;
position: relative;
}
.online::before {
content: "";
position: absolute;
width: 10px;
height: 10px;
background-color: #09d638;
border-radius: 50%;
left: -15px;
top: 5px;
}
}

.main {
height: calc(100% - 95px);
background-color: #f6f6f6;
padding: 2%;
overflow: auto;
.item {
margin-bottom: 10px;
.msg {
max-width: 80%;
padding: 10px;
border-radius: 5px;
display: inline-block;
text-align: left;
margin-top: 2px;
background-color: #ffffff;
}
}
.mymsg {
text-align: right;
.msg{
background-color: #e6e1f5;
}
}
}

.footer {
height: 50px;
background-color: #ffffff;
padding: 5px;
display: flex;
box-sizing: border-box;
align-items: center;

input {
flex: 1;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
}
button {
width: 18%;
padding: 10px;
border: none;
border-radius: 50px;
background-image: linear-gradient(-90deg, #a18cd1 0%, #fbc2eb 100%);
color: #ffffff;
margin-left: 2%;
height: 35px;
}
}
</style>

image-20241008144431103

使用lottie实现入场动画

我们就简单的根据用户名中是否以vip开头,如果是VIP开头的用户加入群聊后,就播放一个入场动画,主要目的是为了学习 lottie 动画

lottie官网: https://airbnb.io/lottie/#/web

免费动画库:https://lottiefiles.com/free-animation

安装

1
npm install lottie-web

在chat.vue代码中添加下面的代码

1
2
3
4
5
<!-- 动画播放器 -->
<div class="lottie-view" v-if="isPlaying">
<div id="lottie"></div>
<div>欢迎 {{ currJoinUser }} 加入群聊</div>
</div>

添加样式

1
2
3
4
5
6
.lottie-view {
position: absolute;
top: 10%;
left: 0;
text-align: center;
}

编写动画逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import lottie from "lottie-web";
import vipJoin from "../assets/vipjoin.json";

// 动画逻辑
const animation = ref(null);
const isPlaying = ref(false);
const currJoinUser = ref(null);

const toggleAnimation = () => {
if (isPlaying.value) return;
isPlaying.value = true;
nextTick(() => {
animation.value = lottie.loadAnimation({
container: document.getElementById("lottie"),
renderer: "canvas",
loop: false,
autoplay: true,
animationData: vipJoin,
});
animation.value.addEventListener("complete", () => {
isPlaying.value = false;
});
});
};

案例中对应的json地址:https://lottiefiles.com/free-animation/success-celebration-Sn1bJRj6pz

然后在 userCount 监听方法中,判断最新加入的用户名是否是vip开头的,如果是就播放动画

1
2
3
4
5
6
7
8
9
10
// 更新数量
socket.on("userCount", (data) => {
state.count.all = data.all;
state.count.online = data.online;
currJoinUser.value = data.userName;
// 判断用户名称是否包含vip
if (data.userName && data.userName.startsWith("vip")) {
toggleAnimation();
}
});

同时后端代码中的 src/message/sockit.ts 文件,下面的代码需要修改一下,多传递一个用户名称

1
2
3
4
5
6
// 向房间内的所有用户广播消息,更新用户数量
io.to(roomId).emit("userCount", {
all: countRes.data,
online: io.engine.clientsCount,
userName // 广播最新加入的用户
});

效果展示

lottie2

完整代码

https://gitee.com/szxio/bilibili-xiaoman---node-js/tree/master/%E8%81%8A%E5%A4%A9%E5%AE%A4demo/test