基础模板
话不多说,直接上代码。
后端使用node/express,首先,把socket.io依赖给装上。
pnpm add socket.io@4.7.5
新建目录sockets下创建chat.js
const socketIo = require('socket.io');
const jwt = require('jsonwebtoken');
const { jwtSecretKey } = require('../config');
const db = require('../db/index');
module.exports = (server) => {
const io = socketIo(server) // 将 Socket.io 绑定到 HTTP 服务器
io.on('connection', (socket) => { // 监听新连接的事件
console.log('用户已连接');
}
socket.on('disconnect', () => { // 监听断开连接事件
console.log('用户已断开连接');
}
return io;
}
在app.js中使用该模块
const http = require('http')
const server = http.createServer(app) // 创建 HTTP 服务器
// ...以上代码省略
// 启用 Socket.io 模块
const socket = require('./sockets/chat')
socket(server)
// 调用 app.listen 方法,指定端口号并启动web服务器
server.listen(2007, '0.0.0.0',function () {
console.log('api server running at http://127.0.0.1:2007')
})
前端代码,安装socket.io-client,安装好依赖后,在chat.vue页面
<script setup lang="ts">
import io from 'socket.io-client'
import type { Socket } from 'socket.io-client'
let socket: Socket
// 1.组件挂载完毕,建立连接
onMounted(() => {
socket = io('http://192.168.0.102:2007', {
transports: ['websocket']
})
// 与服务端连接成功
socket.on('connect', () => {
console.log('连接成功')
})
})
// 2.组件卸载,断开连接
onUnmounted(() => {
socket.close()
})
</script>
此时,在服务端会打印连接成功,并且在页面控制台也会打印连接成功。接下来就可以完成别的业务了。
聊天功能
在服务端chat.js中,我们增加一些业务,比如在聊天室发送一条信息,所有用户都可以看到
const socketIo = require('socket.io');
const jwt = require('jsonwebtoken');
const { jwtSecretKey } = require('../config');
const db = require('../db/index');
module.exports = (server) => {
const io = socketIo(server) // 将 Socket.io 绑定到 HTTP 服务器
io.on('connection', (socket) => { // 监听新连接的事件
console.log('用户已连接');
// 发送消息-监听用户端 sendMessage 事件
socket.on('sendMessage', (message) => {
// 接收消息-用户发送的信息 message 广播给所有用户
io.emit('receiveMessage', message)
})
}
socket.on('disconnect', () => { // 监听断开连接事件
console.log('用户已断开连接');
}
return io;
}
在前端chat.vue页面
<script setup lang="ts">
import io from 'socket.io-client'
import type { Socket } from 'socket.io-client'
let socket: Socket
// 1.组件挂载完毕,建立连接
onMounted(() => {
socket = io('http://192.168.0.102:2007', {
transports: ['websocket']
})
// 与服务端连接成功
socket.on('connect', () => {
console.log('连接成功')
})
// 接收发送的信息
socket.on('receiveMessage', (messages: Message) => {
console.log(messages)
})
// 发送信息
socket.emit('sendMessage', '你好')
})
// 2.组件卸载,断开连接
onUnmounted(() => {
socket.close()
})
</script>
完整业务
实现了登录之后多人聊天,加载历史记录,可发送语音、图片、文字等。使用mysql进行聊天数据存储,腾讯云COS存储桶上传语音、图片文件。某些功能可以有bug,如发送语音等,刚接触的MediaRecorder API浏览器录音功能,还不熟悉,照搬来用的。
完成的页面如下
后端代码:
const socketIo = require('socket.io');
const jwt = require('jsonwebtoken');
const { jwtSecretKey } = require('../config');
const db = require('../db/index');
module.exports = (server) => {
const io = socketIo(server)
const onlineUsers = new Map()
io.use((socket, next) => {
const token = socket.handshake.query.token;
if (token) {
jwt.verify(token, jwtSecretKey, (err, decode) => {
if (err) {
return next(new Error('Authentication error'));
} else {
socket.user = decode;
next();
}
});
} else {
next(new Error('Authentication error'));
}
});
io.on('connection', (socket) => {
console.log('用户已连接');
// 添加用户到在线用户集合
const userId = socket.user.id;
const userNickname = socket.user.nickname;
const userAvatar = socket.user.avatar;
onlineUsers.set(userId, { id: userId, nickname: userNickname, avatar: userAvatar });
// 广播在线用户列表
io.emit('onlineUsers', Array.from(onlineUsers.values()));
// 加载更多历史信息
socket.on('loadMoreMessages', (data) => {
const timestamp = data.timestamp ? data.timestamp / 1000 : Date.now() / 1000; // 转换为秒
const sql = `
SELECT
m.id,
m.message,
m.audioUrl,
m.imageUrl,
m.user_id,
UNIX_TIMESTAMP(m.timestamp) AS timestamp,
u.nickname,
u.avatar
FROM
haw_chat_messages m
INNER JOIN
haw_users u ON m.user_id = u.id
WHERE
m.timestamp < FROM_UNIXTIME(?)
ORDER BY
m.timestamp DESC
LIMIT 10
`
db.query(sql, [timestamp], (err, results) => {
if (err) throw err
socket.emit('chatMsgList', results.reverse())
})
})
// 发送消息
socket.on('sendMessage', (message) => {
const { user_id, text, audioUrl, imageUrl } = message
const insertMessageSql = 'INSERT INTO haw_chat_messages (user_id, message, audioUrl, imageUrl) VALUES (?, ?, ?, ?)'
db.query(insertMessageSql, [user_id, text || '', audioUrl || '', imageUrl || ''], (err, result) => {
if (err) console.error(err)
const getUserSql = 'SELECT nickname, avatar FROM haw_users WHERE id = ?'
db.query(getUserSql, [user_id], (err, userResult) => {
if (err) console.error(err)
const user = userResult[0]
// 广播发送的信息
io.emit('receiveMessage', {
id: result.insertId,
user_id,
message: text,
audioUrl: audioUrl,
imageUrl:imageUrl,
timestamp: new Date().getTime() / 1000,
nickname: user.nickname,
avatar: user.avatar
})
})
})
})
socket.on('disconnect', () => {
console.log('用户已断开连接');
onlineUsers.delete(userId);
// 广播更新后的在线用户列表
io.emit('onlineUsers', Array.from(onlineUsers.values()));
});
});
return io;
};
前端代码:以下xxxxx开通腾讯云COS存储桶,填上自己的信息
<script setup lang="ts">
import io from 'socket.io-client'
import { onMounted } from 'vue'
import type { Socket } from 'socket.io-client'
import { onUnmounted } from 'vue'
import { ref } from 'vue'
import { useUserStore } from '@/stores'
import { ElMessage } from 'element-plus'
import { nextTick } from 'vue'
import { getTimeFull } from '@/utils/filter'
import type { Message, OnlineUser } from '@/types/chat'
import COS from 'cos-js-sdk-v5'
const cos = new COS({
SecretId: 'xxxxx',
SecretKey: 'xxxxx'
})
const messagesList = ref<Message[]>([])
const newMessage = ref('')
const userStore = useUserStore()
const onlineUsers = ref<OnlineUser[]>([])
const chatWindow = ref<HTMLElement | null>(null)
// 1. 挂载完毕-建立连接 组件卸载-关闭连接
let socket: Socket
onMounted(() => {
socket = io('http://192.168.0.102:2007', {
transports: ['websocket'],
query: {
token: userStore.user?.token
}
})
// 连接失败:未登录
socket.on('connect_error', (error) => {
if (error.message === 'Authentication error') {
ElMessage.error('认证错误,请登录后重试')
userStore.toggleLogin(true)
userStore.logout()
} else {
ElMessage.error('连接失败,请重试')
}
console.error('连接错误:', error)
})
// 连接成功
socket.on('connect', () => {
console.log('连接成功')
// 初始加载信息
loadMoreMessages()
})
// 获取历史记录
socket.on('chatMsgList', (messages: Message[]) => {
const oldScrollHeight = chatWindow.value?.scrollHeight || 0
messagesList.value = [...messages, ...messagesList.value]
isLoading.value = false
if (!messages.length) {
return ElMessage.success('聊天记录加载完了')
}
nextTick(() => {
if (chatWindow.value && !initiaMsg.value) {
chatWindow.value.scrollTop =
chatWindow.value.scrollHeight - oldScrollHeight
}
if (chatWindow.value && initiaMsg.value) {
chatWindow.value.scrollTop = chatWindow.value.scrollHeight
initiaMsg.value = false
}
})
})
// 每次接收新的消息
socket.on('receiveMessage', (messages: Message) => {
messagesList.value.push(messages)
nextTick(() => {
if (chatWindow.value) {
chatWindow.value.scrollTop = chatWindow.value.scrollHeight
}
})
})
// 接收在线人数
socket.on('onlineUsers', (user: OnlineUser[]) => {
onlineUsers.value = user
})
})
onUnmounted(() => {
socket.close()
})
// 发送消息
const sendMessage = () => {
if (newMessage.value.trim() !== '') {
socket.emit('sendMessage', {
user_id: userStore.user?.id,
text: newMessage.value
})
newMessage.value = ''
}
}
// 初始加载更多历史信息
const initiaMsg = ref(true)
const loadMoreMessages = () => {
const oldestMessage = messagesList.value[0]
const timestamp = oldestMessage
? new Date(oldestMessage.timestamp).getTime() * 1000
: Date.now()
socket.emit('loadMoreMessages', { timestamp })
}
// 加载更多
const isLoading = ref(false)
const handleScroll = () => {
if (chatWindow.value && chatWindow.value.scrollTop === 0) {
isLoading.value = true
loadMoreMessages()
}
}
// 发送语音消息
const mediaRecorder = ref<MediaRecorder | null>(null)
const isRecording = ref(false)
const audioChunks = ref<Blob[]>([]) // 保存录音数据的数组
const startRecording = async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return ElMessage.error('当前您的浏览器不支持录音功能')
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder.value = new MediaRecorder(stream)
audioChunks.value = []
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data)
}
mediaRecorder.value.onstop = async () => {
if (audioChunks.value.length > 0) {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/webm' })
await uploadAudio(audioBlob)
}
}
mediaRecorder.value.start()
isRecording.value = true
}
const stopAndSendRecording = () => {
if (!isRecording.value) return
mediaRecorder.value?.stop()
isRecording.value = false
}
const uploadAudio = (audioBlob: Blob) => {
const fileName = `audio-${Date.now()}.webm`
cos.putObject(
{
Bucket: 'xxxxx',
Region: 'xxxxx',
Key: fileName,
Body: audioBlob
},
(err, data) => {
if (err) {
ElMessage.error('语音发送失败,请重试')
console.error('Audio upload error:', err)
} else {
const audioUrl = `https://${data.Location}`
socket.emit('sendMessage', {
user_id: userStore.user?.id,
audioUrl
})
ElMessage.success('语音发送成功')
}
}
)
}
const cancelRecording = () => {
if (isRecording.value) {
mediaRecorder.value?.stop()
isRecording.value = false
ElMessage.info('取消录音')
}
}
// 播放语音
const audioRefs = ref<(HTMLAudioElement | null)[]>([])
// 播放
const onAudio = (index: number) => {
const audioElement = audioRefs.value[index]
console.dir(audioRefs.value[index])
if (audioElement) {
if (audioElement.paused) {
audioElement.play()
} else {
audioElement.pause()
}
}
}
// 上传图片
const selectedFile = ref<File | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const file = target.files[0]
const fileType = file.type
const fileSize = file.size
if (fileSize > 1024 * 1024) {
ElMessage.error('图片大小不能超过 1MB')
return
}
const validTypes = ['image/jpeg', 'image/jpg', 'image/png']
if (!validTypes.includes(fileType)) {
ElMessage.error('图片格式为 jpg / jpeg / png')
return
}
selectedFile.value = file
uploadImage(file)
}
}
const uploadImage = (file: File) => {
const fileName = `image-${Date.now()}.${file.name.split('.').pop()}`
cos.putObject(
{
Bucket: 'xxxxx',
Region: 'xxxxx',
Key: fileName,
Body: file
},
(err, data) => {
if (err) {
ElMessage.error('图片发送失败,请重试')
console.error('Audio upload error:', err)
} else {
const imageUrl = `https://${data.Location}`
socket.emit('sendMessage', {
user_id: userStore.user?.id,
imageUrl
})
ElMessage.success('图片发送成功')
}
}
)
}
</script>
<template>
<div class="chat-container">
<div class="chat-left">
<div v-for="user in onlineUsers" :key="user.id" class="online-user">
<el-avatar :src="user.avatar" />
<span class="nickname">{{ user.nickname }}</span>
</div>
</div>
<div class="chat-right">
<div class="chat-title">
<p><span></span> 当前在线人数 {{ onlineUsers.length }}</p>
<h2>h-Chat</h2>
</div>
<div
v-loading="isLoading"
class="chat-window"
ref="chatWindow"
@scroll="handleScroll"
>
<div>
<div
v-for="(message, index) in messagesList"
:key="message.id"
class="chat-message"
>
<!-- 接收消息 -->
<div
class="other-message"
v-if="message.user_id !== userStore.user?.id"
>
<el-avatar :src="message.avatar" />
<div class="content">
<p class="content-other">
<span class="content-other-nickname">
{{ message.nickname }}
</span>
<span class="content-other-time">
{{ getTimeFull(message.timestamp) }}
</span>
</p>
<div
v-if="message.audioUrl"
class="audioBox"
@click="onAudio(index)"
>
<audio
:ref="(el) => (audioRefs[index] = el as HTMLAudioElement)"
class="audioBtn"
controls
>
<source :src="message.audioUrl" type="audio/mp3" />
</audio>
<el-icon :size="30"><VideoPlay /></el-icon>⬝⬝⬝⬝
</div>
<div v-else-if="message.imageUrl">
<el-image
style="width: 100%; height: 100%; border-radius: 5px"
:src="message.imageUrl"
fit="cover"
/>
</div>
<div v-else>
<p>{{ message.message }}</p>
</div>
</div>
</div>
<!-- 发送消息 -->
<div
class="own-message"
v-if="message.user_id === userStore.user?.id"
>
<div class="content">
<p class="content-other">
<span class="content-other-time">
{{ getTimeFull(message.timestamp) }}
</span>
<span class="content-other-nickname">
{{ message.nickname }}
</span>
</p>
<div
v-if="message.audioUrl"
class="audioBox"
@click="onAudio(index)"
>
<audio
:ref="(el) => (audioRefs[index] = el as HTMLAudioElement)"
class="audioBtn"
controls
>
<source :src="message.audioUrl" type="audio/mp3" />
</audio>
⬝⬝⬝⬝<el-icon :size="30"><VideoPlay /></el-icon>
</div>
<div v-else-if="message.imageUrl">
<el-image
style="width: 100%; height: 100%; border-radius: 5px"
:src="message.imageUrl"
fit="cover"
/>
</div>
<div v-else>
<p>{{ message.message }}</p>
</div>
</div>
<el-avatar :src="message.avatar" />
</div>
</div>
</div>
</div>
<div class="chat-input">
<button
@touchstart="startRecording"
@touchend="stopAndSendRecording"
@touchcancel="cancelRecording"
>
<el-icon :size="20"><Microphone /></el-icon>
</button>
<input
v-model="newMessage"
placeholder="输入消息..."
@keyup.enter="sendMessage"
style="outline: none"
/>
<button @click="triggerFileInput">
<input
ref="fileInputRef"
class="file-input"
type="file"
@change="onFileChange"
/>
<el-icon :size="20"><Picture /></el-icon>
</button>
<button @click="sendMessage">发送</button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.chat-container {
display: flex;
justify-content: space-between;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
.chat-left {
border-right: 1px solid #ddd;
overflow-y: auto;
.online-user {
display: flex;
align-items: center;
margin: 5px 0;
.nickname {
margin-left: 5px;
}
}
}
.chat-title {
position: relative;
display: flex;
line-height: 32px;
margin-bottom: 5px;
p {
position: absolute;
left: 0;
top: 0;
span {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #3dc029;
}
}
h2 {
width: 100%;
text-align: center;
}
}
.chat-window {
height: 500px;
overflow-y: scroll;
padding: 10px;
border: 1px solid #ddd;
margin-bottom: 10px;
}
.chat-message {
margin-bottom: 10px;
.own-message {
display: flex;
justify-content: right;
align-items: center;
.content {
width: 195px;
padding: 10px;
margin-right: 5px;
text-align: right;
border-radius: 10px;
background-color: #8ce88a;
box-shadow: 2px 2px 5px #bebebe;
&-other {
display: flex;
justify-content: space-between;
align-items: center;
span {
display: inline-block;
}
&-nickname {
width: 50px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 14px;
}
&-time {
font-size: 12px;
}
}
.audioBox {
display: flex;
align-items: center;
justify-content: right;
height: 30px;
.audioBtn {
display: none;
}
}
}
}
.other-message {
display: flex;
justify-content: left;
align-items: center;
.content {
width: 200px;
padding: 10px;
margin-left: 5px;
border-radius: 10px;
background-color: #ebf0f2;
box-shadow: 2px 2px 5px #bebebe;
&-other {
display: flex;
justify-content: space-between;
align-items: center;
span {
display: inline-block;
}
&-nickname {
width: 50px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
font-size: 14px;
}
&-time {
font-size: 12px;
}
}
.audioBox {
display: flex;
align-items: center;
height: 30px;
.audioBtn {
display: none;
}
}
}
}
}
.chat-input {
display: flex;
input {
flex: 1;
padding: 10px;
}
button {
padding: 10px;
}
.file-input {
display: none;
}
}
}
</style>