后端代码编写
node环境初始化
新建一个空文件夹node,初始化node环境
修改 packages.json,只保留 scripts 和 dependencies
1 2 3 4 5 6
| { "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": {}, }
|
ts环境初始化
如果没有tsc命令,需要提前在全局安装 typescript
1
| npm install typescript ts-node -g
|
已经安装过的可忽略
检查ts是否安装成功

执行完 tsc --init
会自动生成 tsconfig.json,修改里面的三个配置
1 2 3
| "experimentalDecorators": true, "emitDecoratorMetadata": true, "strict": false,
|
- “experimentalDecorators”: true
- 含义: 启用对装饰器的实验性支持。装饰器是一种特殊类型的声明,它能够被附加到类声明、方法、访问符、属性或参数上。装饰器在 TypeScript 中是实验性的特性,这意味着它们可能在未来的版本中发生变化。
- 用途: 如果你使用装饰器(例如 Angular 的装饰器),你需要启用这个选项。
- “emitDecoratorMetadata”: true
- 含义: 启用装饰器元数据的生成。装饰器元数据是编译器在装饰器上附加的额外信息,这些信息可以在运行时通过反射 API 访问。
- 用途: 如果你需要使用反射 API 来访问装饰器元数据,你需要启用这个选项。
- “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
Prisma 官网 https://www.prisma.io/docs
然后再项目根目录执行下面的命令初始化一个基于mysql的项目
1
| prisma init --datasource-provider mysql
|
此时的项目目录结构如下

在 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 文件的数据库连接地址

1
| DATABASE_URL="mysql://root:abc123@localhost:3306/hichat"
|
然后执行下面命令执行SQL语句创建表
执行完成后,打开数据库就会发现自动帮我们创建好了表

编写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 { constructor(@inject(PrismaDB) private PrismaDB: PrismaDB) {}
public async add(userName:string) { return await this.PrismaDB.prisma.user.create({ data:{ 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) {}
@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 { 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";
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, }, });
const groupList = {};
io.on("connection", (socket) => { socket.on("join", async ({ roomId, id, userName }) => { console.log(userName, "连接到房间"); socket.join(roomId); if (groupList[roomId]) { groupList[roomId].push({ id, userName, roomId, socketId: socket.id, }); } 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]; 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();
container.bind<PrismaClient>("PrismaClient").toFactory(() => { return () => new 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) => { 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

前端代码实现
初始化项目

安装依赖
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>
|
点击加入群聊后会先去查询当前用户名是否在表中存在,如果不存在,则会新建一个用户保存到表中,并返回用户信息,如果存在,则直接返回用户信息跳转到聊天界面
群聊实现
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>
|

使用lottie实现入场动画
我们就简单的根据用户名中是否以vip开头,如果是VIP开头的用户加入群聊后,就播放一个入场动画,主要目的是为了学习 lottie 动画
lottie官网: https://airbnb.io/lottie/#/web
免费动画库:https://lottiefiles.com/free-animation
安装
在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; 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 });
|
效果展示

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