vue+express+socket.io实现简单多人聊天室

577 阅读6分钟

基础模板

话不多说,直接上代码。

后端使用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>