通过WebSocket搭建一个简易的即时聊天平台
使用Vue3 + socket.io + Pinia
仓库: websocket-nest-vue/vue-websocket
创建Vue3项目
bash
pnpm create vite@latest
安装依赖
bash
pnpm i socket.io-client pinia pinia-plugin-persistedstate
组件库
bash
pnpm i element-plus @element-plus/icons-vue
配置后端接口
bash
VITE_NEST_PORT=http://localhost:3000
VITE_SOCKET_PORT=http://localhost:3001
载入ElementPlus和Pinia
ts
import { createApp } from "vue";
import "./style.css";
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import App from "./App.vue";
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
const app = createApp(App);
app.use(pinia);
app.use(ElementPlus);
app.mount("#app");
Socket.io
ts
import { io } from "socket.io-client";
const client = io(`${import.meta.env.VITE_SOCKET_PORT}`, {
autoConnect: true,
});
export { client };
用户信息和聊天记录持久化
ts
import { defineStore } from "pinia";
import { ref } from "vue";
import { IMessage } from "../types/message.interface";
export const useMessageStore = defineStore(
"messagestore",
() => {
const messageList = ref<Partial<IMessage>[]>([]);
const cleanLocalHistory = () => (messageList.value = []);
return { messageList, cleanLocalHistory };
},
{
persist: true,
}
);
ts
import { defineStore } from "pinia";
import { ref } from "vue";
interface IUser {
username: string;
id: string;
}
export const useUserStore = defineStore(
"userstore",
() => {
const user = ref<IUser>();
const saveuser = (user1: IUser) => {
user.value = user1;
};
const logOut = () => {
user.value = undefined;
};
return { user, saveuser, logOut };
},
{
persist: true,
}
);
聊天信息会在聊天组件使用,所以将Message
类型独立一份出来:
ts
interface IMessage {
id: string;
userId: string;
username: string;
content: string;
createTime: string;
}
export type { IMessage };
页面和组件的编写
vue
<template>
<div class="container">
<div
v-if="!userStore.user"
class="createuser"
@click="createUserVisible = true"
>
Create User
</div>
<div v-else class="user">
<div style="display: flex; align-items: center">
<el-icon :size="20"><Avatar /></el-icon>
<div>{{ userStore.user.username }}</div>
</div>
</div>
<ChatBox />
<div class="tools">
<el-icon :size="20" @click="logOut"><BottomLeft /></el-icon>
<el-icon :size="20" @click="cleanHistory">
<Delete />
</el-icon>
</div>
<CreateUser
v-if="createUserVisible"
:visable="createUserVisible"
@close="createUserVisible = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useUserStore } from "./stores/user.store";
import { useMessageStore } from "./stores/message.store";
import { Delete, Avatar, BottomLeft } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import CreateUser from "./components/CreateUser.vue";
import ChatBox from "./components/ChatBox.vue";
const userStore = useUserStore();
const messageStore = useMessageStore();
const createUserVisible = ref(false);
const logOut = () => {
ElMessageBox.confirm("Are you sure to logout this user?")
.then(() => {
userStore.logOut();
})
.catch(() => {
// catch error
});
};
const cleanHistory = () => {
ElMessageBox.confirm("Are you sure to clean all messages locally?")
.then(() => {
messageStore.cleanLocalHistory();
})
.catch(() => {
// catch error
});
};
</script>
<style scoped>
.container {
display: flex;
flex-direction: column;
justify-content: center;
width: 100vw;
height: 100vh;
background: linear-gradient(#c2c1c1, #7e7e7e);
}
.createuser {
color: white;
margin: 20px;
user-select: none;
cursor: pointer;
width: fit-content;
}
.user {
display: flex;
align-items: center;
justify-content: center;
margin: 20px;
gap: 20px;
}
.tools {
display: flex;
justify-content: flex-end;
margin: 15px;
gap: 20px;
padding-right: 30px;
}
.tools > .el-icon {
cursor: pointer;
}
</style>
vue
<template>
<div class="chat">
<MessageList :messageList="messageStore.messageList" />
<div class="editor">
<textarea
v-model="editor_input"
type="text"
@keydown.enter.prevent="sendMessage"
class="editor_input"
/>
<span class="editor_send" @click="sendMessage">Send</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, ref } from "vue";
import MessageList from "../components/MessageList.vue";
import { ElMessage } from "element-plus";
import { useUserStore } from "../stores/user.store";
import { useMessageStore } from "../stores/message.store";
import { client } from "../libs/socket.io";
onMounted(() => {
client.connect();
scroll();
});
const userStore = useUserStore();
const messageStore = useMessageStore();
const scroll = () => {
const messageBox = document.querySelector(".messages");
if (messageBox) {
messageBox.scrollTop = messageBox.scrollHeight;
}
};
const editor_input = ref<string>();
client.on("connect", () => {
ElMessage.success("Socket connected.");
});
client.on("message", (message) => {
messageStore.messageList.push(message);
nextTick(() => {
scroll();
});
});
const sendMessage = () => {
if (!editor_input.value) {
ElMessage.error("Please input message.");
return;
}
if (!userStore.user) {
ElMessage.error("Please create user first.");
localStorage.removeItem("user");
} else {
client.emit("createMessage", {
userId: userStore.user.id,
content: editor_input.value,
});
editor_input.value = "";
}
};
</script>
<style scoped>
.chat {
display: flex;
justify-content: space-between;
flex-direction: column;
width: 60vw;
height: 60vh;
background: #eeeeee;
border-radius: 5px;
margin: 0 auto;
}
.editor {
display: flex;
flex-direction: column;
background: white;
margin: 5px;
border-radius: 5px;
}
.editor_input {
resize: none;
border: none;
border-radius: 2px;
outline: none;
padding: 10px;
}
.editor_send {
width: fit-content;
padding: 3px 6px;
border-radius: 3px;
cursor: pointer;
margin-left: auto;
user-select: none;
transition: all 0.3s ease-in-out;
color: rgb(0, 132, 255);
}
.editor_send:hover {
text-shadow: rgb(0, 153, 255) 1px 0 10px;
}
</style>
vue
<template>
<el-dialog
v-model="props.visable"
title="Create User: "
:before-close="handleClose"
>
<div style="display: flex; align-items: center; flex-wrap: wrap">
<span style="margin: 0 5px">UserName: </span>
<el-input v-model.trim="username" @keyup.enter="createUser" />
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">Cancel</el-button>
<el-button type="primary" @click="createUser"> Confirm </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { useUserStore } from "../stores/user.store";
import { ref } from "vue";
const props = defineProps({
visable: {
type: Boolean,
},
});
const user = useUserStore();
const emit = defineEmits(["close"]);
const username = ref<string>();
const handleClose = () => {
emit("close");
};
const createUser = () => {
fetch(`${import.meta.env.VITE_NEST_PORT}/user`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: username.value,
}),
})
.then((res) => res.json())
.then((data) => {
if (data.id) {
ElMessage.success("Create user success.");
user.saveuser(data);
emit("close");
}
})
.catch(() => {
ElMessage.error("Oops, Failed to create user.");
});
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
}
</style>
vue
<template>
<div class="messages">
<div
class="messages_container"
v-for="message in messageList"
:key="message.id"
:style="{
flexDirection: message.userId === user.user?.id ? 'row-reverse' : 'row',
}"
>
<div
class="messages_avatar"
:style="{
backgroundColor:
message.userId === user.user?.id ? '#409EFF' : '#f56c6c',
}"
>
{{ message.username?.charAt(0).toUpperCase() }}
</div>
<div class="message_content">
{{ message.content }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "../stores/user.store";
import { IMessage } from "../types/message.interface";
defineProps({
messageList: {
type: Array<Partial<IMessage>>,
},
});
const user = useUserStore();
</script>
<style scoped>
.messages {
width: 100%;
height: 100%;
overflow: auto;
}
.messages::-webkit-scrollbar {
width: 8px;
}
.messages::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 5px;
}
.messages::-webkit-scrollbar-thumb:hover {
background: #aeaeae;
}
.messages::-webkit-scrollbar-track {
border-radius: 10px;
}
.messages_container {
display: flex;
align-items: center;
margin: 15px 10px;
}
.messages_avatar {
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
color: white;
font-size: larger;
}
.message_content {
background-color: white;
padding: 8px 15px;
border-radius: 5px;
margin: 0 10px;
max-width: 50%;
}
</style>
启动测试
bash
pnpm dev