Nuxt3 Socket.IO 实现实时聊天室

V2 版本总览2024/10/6编辑于 2026/3/36文章
Nuxt3 Socket.IO 实现实时聊天室
我最爱莲了!

简单记录一下聊天室需要实现的功能, 因为代码逻辑比较多, 写着写着我自己都理不清了

功能概览

  • 聊天室路由
  • Nuxt3 layouts
  • Nuxt3 使用 Socket.IO
  • MongoDB 如何设计 ChatRoom Model 和 Chat Message Model
  • 实时消息通知实现逻辑
  • 消息持久化
  • 错误处理
  • 已读消息(咕咕咕中)
  • 消息 Reactions(咕咕咕中)
  • 撤回消息(咕咕咕中)
  • 群聊(咕咕咕中)

聊天室路由

聊天室路由被设置为

  • /message - 默认消息界面
  • /message/notice - 论坛消息通知(收到用户点赞,评论等)
  • /message/system - 论坛全体消息通知(管理员发布的消息通知)
  • /message/user/[chatroom-id] 私聊聊天室(chatroom-id 根据两个用户的 uid 拼接生成)
  • /message/group/[group-id] 群聊聊天室(开发中)

刚开始我本来想把路由设计成像 Telegramt.me/kungalgame 那样干净的路由

但是我发现实现起来过于困难, 因为路由传参不好实现, 目前的设计更加符合直觉, 也方便之后的扩展, 于是就沿用这个路由了

其中 chatroom-id 通过一种简单的方式生成了用户双方独有且符合直觉的 chatroom-id

TypeScript
export const generateRoomId = (uid1: number, uid2: number): string => {
  const sortedUids = [uid1, uid2].sort((a, b) => a - b)
  return `${sortedUids[0]}-${sortedUids[1]}`
}

这会形成形如 1-2, 10-1007 这样的 chatroom-id, 并且双方用户进入聊天使用的是同一个聊天室

不会出现一个用户创建聊天室之后, 另一个用户, 又进入了一个由相同用户的两个 uid 生成的但是顺序不同的 chatroom-id 这种情况

例如 2-1, 1007-10

Nuxt3 layouts

介绍

Nuxt3 有 layouts 的用法, 可以轻易的定义出符合页面设计特性的通用的布局

本次我们也使用了这个布局, 在我们项目的 /layouts/message.vue 文件中

pages 下想要使用的页面中写入

TypeScript
definePageMeta({
  layout: 'message'
})

即可使用这个布局

问题

不过这个布局有些坑, 那就是不能使用 <NuxtPage /> 传递参数, 经过我们的多此实验, 发现对于 Nuxt 来说, layouts 的加载渲染是要快于 pages

然而我们的用户界面布局也是可以重用的, 且必须要传递 uid 这个参数, 否则会造成数倍的数据库查询代价

有一个简单的方法是解决这个问题, 那就是使用 defineNuxtRouteMiddleware 定义一个 Nuxt route middleware 来辅助传递 params

可以参考 https://stackoverflow.com/questions/76127659/route-params-are-undefined-in-layouts-components-in-nuxt-3

对于我们的项目我们在 /middleware 文件夹下新建一个 deliver-uid.ts 文件, 写入

TypeScript
export default defineNuxtRouteMiddleware((to) => {
  useState('routeParamUid', () => to.params.uid)
})

然后在想要使用这个 middleware 的页面中写入

TypeScript
definePageMeta({
  middleware: 'deliver-uid'
})

这样就可以实现效果了, 但是!。。还是有问题

我们的页面是可以通过直接输入用户的 uid 访问的, 类似于 https://www.kungal.com/kungalgamer/1/info 这样的路由

然而, 当我们直接使用页面的固定链接访问页面时, 这个中间件并没有生效

这其实是预期之内的结果, 因为中间件仅会在指定了 definePageMetadeliver-uid 的页面使用

可以将 deliver-uid.global.ts 以便其在全局生效, 但是由于我们的 uid 并不是全局的, 所以也无法使用

用户页面中的 uid 获取方法为

TypeScript
const uid = computed(() => {
  return parseInt((route.params as { uid: string }).uid)
})

这个 uid 是根据 route params 计算出的, 而不是根据页面的 Url 动态计算得出的, 我记得远古的时候根据 Url 计算似乎会导致奇怪的水合错误

所以。。懒得折腾用户页面的布局了, 沿用现在的路由设计

我们的消息界面当然是要用户登录之后才能使用的, 所以必须加上前端的鉴权 middleware

TypeScript
definePageMeta({
  middleware: 'auth'
})

然后在 /public/robot.txt 中写入 Disallow: /message 以免影响网站 SEO

Nuxt3 使用 Socket.IO

介绍

Socket.IO 是一个用于实时通信的库 Realtime application framework (Node.JS server)

为什么选用它是因为对于 Nuxt 来说, WebSocket 直接使用起来貌似比 Socket.IO 还要麻烦一些(根据我们工具库都靠自己造的原则来说

实际上 Nuxt3 使用的 server 框架 nitro 原生支持 websocket, 它使用了 crossws, 不过实在是找不到什么参考, 用起来太吃力了, 还是换回了 Socket.IOnitro 性能居然比 hono 还要好一点

还有就是 Socket.IO 兼容性比较好, 浏览器环境不支持 WebSocket 时会自动切换到 pooling 等方式上

但是意义不是很大, 因为这都什么年代了还有浏览器不支持 ws 吗

官方用法

这个实际上官方说明了 Nuxt3 如何使用 Socket.IO, 但是实际操作中有一些问题

Socket.IO 的文档: https://socket.io/how-to/use-with-nuxt

Nitro 的文档: https://nitro.unjs.io/guide/websocket

crossws 的文档: https://crossws.unjs.io/guide

但是这会带来一些问题, 诸如在 /server/api 中定义的 route 无法使用 io 实例等等

我们的用法

首先安装一下必要的依赖(虽然我们坚持不使用多余的包, 例如 ui 库, VueUse, Lodash 等, 但是这种包还是要安装的)

Shell
pnpm i socket.io socket.io-client engine.io

然后在 /server/plugins 文件夹下新建一个 socket.io.ts 的文件, 编写下面的代码

TypeScript
import env from '../env/dotenv'
import jwt from 'jsonwebtoken'
import { parse } from 'cookie-es'
import { Server as Engine } from 'engine.io'
import { Server } from 'socket.io'
import { defineEventHandler } from 'h3'
import { handleSocketRequest } from '~/server/socket/handleSocketRequest'
import type { NitroApp } from 'nitropack'
import type { Socket } from 'socket.io'
import type { KUNGalgamePayload } from '~/types/utils/jwt'

export interface KUNGalgameSocket extends Socket {
  payload?: KUNGalgamePayload
}

export default defineNitroPlugin((nitroApp: NitroApp) => {
  const engine = new Engine()
  const io = new Server()

  io.bind(engine)

  io.use((socket: KUNGalgameSocket, next) => {
    const token = parse(socket.request.headers.cookie || '')
    const refreshToken = token['kungalgame-moemoe-refresh-token']

    try {
      const payload = jwt.verify(
        refreshToken,
        env.JWT_SECRET || ''
      ) as KUNGalgamePayload
      socket.payload = payload
      next()
    } catch (error) {
      return error
    }
  })

  io.on('connection', handleSocketRequest)

  nitroApp.router.use(
    '/socket.io/',
    defineEventHandler({
      handler(event) {
        // event.node.req.context = event.context
        // @ts-expect-error private method
        engine.handleRequest(event.node.req, event.node.res)
        event._handled = true
      },
      websocket: {
        open(peer) {
          // @ts-expect-error private method
          const nodeContext = peer.ctx.node
          const req = nodeContext.req

          // @ts-expect-error private method
          engine.prepare(req)

          const rawSocket = nodeContext.req.socket
          const websocket = nodeContext.ws

          // @ts-expect-error private method
          engine.onWebSocket(req, rawSocket, websocket)
        }
      }
    })
  )
})

需要说明的是, 在 plugins 下似乎无法使用我们已有的工具函数 getCookieTokenInfo, 需要重新编写一遍鉴权的逻辑

io.use 这里就是使用了 Socket.IO 的鉴权中间件

io.on 这里的 connection 事件需要执行我们的消息首发逻辑, 过程比较多, 我们在 /server 下新建一个 socket 目录, 新建 handleSocketRequest.ts 重新编写我们的处理消息逻辑

鉴权好之后用户的 jwt payload 会被放在 socket 实例中的 payload 属性中

MongoDB 如何设计 ChatRoom Model 和 Chat Message Model

我们需要考虑到的不只是一个简单的聊天功能, 我们尝试将聊天功能与 Telegram 这样美观先进的 chat-app 对标,因此我们的 db-model 需要设计的更加功能强大、易于扩展(但是更多的功能是否会咕咕咕我就不知道了

考虑到我们要兼容一下 private chatchat group 这两种类型的聊天, 我们需要新建两个 MongoDB model

/server/model/types/chat-message.d.ts

TypeScript
import type { UserAttributes } from './user'

export interface MessageRead {
  uid: number
  read_time: number
}

export interface MessageReaction {
  uid: number
  reaction: string
}

export interface ChatMessageAttributes {
  cmid: number
  chatroom_name: string
  sender_uid: number
  receiver_uid: number
  content: string
  to_uid: number
  time: number
  status: 'pending' | 'sent' | 'read'
  is_recalled: boolean
  recalled_time: number
  read_by: MessageRead[]
  reactions: MessageReaction[]

  user: UserAttributes[]

  created: Date
  updated: Date
}

我们注意到这个 model, 有几点需要解释

cmidcrid 是每个 document 的唯一 id, 我们的项目为了保证纯净的 Url, 没有使用 MongoDB 的 _id

content 为聊天的消息内容, 目前不会支持 Markdown

status 为消息的状态, pending 为发送中, sent 为已发送, read 为已读

is_recalled 表示消息是否被撤回

read_by 是一个已读用户的数组, 标明这条消息被哪些用户已读 (这个功能似乎有点损害用户的隐私, 初版不会上线)

reactions 对消息的 reactions, 可以点赞这条消息等

/server/model/types/chat-room.d.ts

TypeScript
interface LastMessage {
  sender_uid: number
  sender_name: string
  content: string
  time: number
}

export interface ChatRoomAttributes {
  crid: number
  name: string
  type: 'private' | 'group'
  avatar: string
  participants: number[]
  admins: number[]
  last_message: LastMessage

  created: Date
  updated: Date
}

用户每新建一个与其它用户的聊天, 或者创建一个群组, 都是新建了一个 chatroom

type 表明这个 chatroom 的类型, private 为私聊, group 为群聊(也许还有 channel 为频道, 咕咕咕咕咕咕咕

participants 表示这个 chatroom 中有哪些用户, 如果是私聊的话这个数组中只有私聊用户两人的 uid

admins 表示 chatroom 的管理员, 仅对群组生效

last_message 表示 chatroom 的最后一条消息, 用于消息界面左侧的预览

可扩展性

例如如果要增加一个禁止用户私聊, 或者封禁群组的功能

只需要在这些 model 上面新建一个 status 的字段即可

为什么要用 last_message

一是快速, 如果要获取用户所加入的所有聊天室, 并根据聊天的时间进行排序, 那么只需要根据 last_message.time 对 documents 进行排序即可

二是方便, 经过我们的实践, 发现这个方法比使用 virtue key populate 出 message 都要方便

实时消息通知实现逻辑

整个消息的收发逻辑是这样的

用户加入聊天

当用户进入聊天页面的时候, 会为用户自动创建房间, socket 会发送一个 private:join 的 event, 并携带被聊天用户的 uid 发送给后端的 socket 实例

同时还会调用一个 getMessageHistory 的函数, 用以检查用户是否有历史消息

如果有历史消息, nextTick 之后会将用户消息窗口滚动到新消息的位置

前端

TypeScript
onMounted(async () => {
  socket.emit('private:join', uid)
  messages.value = await getMessageHistory()
  nextTick(() => scrollToBottom())
})

后端

TypeScript
  socket.on('private:join', (receiverUid: number) => {
    const uid = socket.payload?.uid
    userSockets.set(uid, socket)
    const roomId = generateRoomId(uid, receiverUid)
    socket.join(roomId)
  })

socket 检测到用户 private:join 的 event, 获取到前端发送的被聊天用户 uid, 此时, 后端拥有了聊天用户和被聊天用户双方的 uid

根据我们最前面提到的 generateRoomId 的逻辑, 两个用户互相聊天生成的 chatroom-id 的唯一的, 所以我们可以这么做

  • 将用户 1 的 { uid: socket } 加入 userSockets 这个 Map
  • 将用户 1 的 socket 实例 join 进 roomId 这个唯一的房间

同理, 用户 2 乃至用户 n 都会被加入 roomId 这个房间

由于用户自己的 uid 是通过 jwt payload 获取到的, 这意味着用户发送的消息不可能被假冒, 实现了鉴权的功能

由于用户双方生成的 chatroom-id 是唯一的, 这意味着不可能有其它用户发送的消息进入两个用户私聊的房间, 实现了私聊的功能

用户发出消息

前端

TypeScript
const sendMessage = async () => {
  if (!messageInput.value.trim()) {
    return
  }
  socket.emit('message:sending', uid, messageInput.value)
}

这里直接创建一个 message:sending 的 event 用于发送消息

后端

TypeScript
  socket.on('message:sending', async (receiverUid: number, content: string) => {
    const uid = socket.payload?.uid
    const sendingMessageUserSocket = userSockets.get(uid)

    const message = await sendingMessage(uid, receiverUid, content)

    const roomId = generateRoomId(uid, receiverUid)
    sendingMessageUserSocket.emit('message:sent', message)
    sendingMessageUserSocket.to(roomId).emit('message:received', message)
  })
  1. 监测到前端发送的 message:sending 事件, 从刚刚我们保存的 userSockets Map 中尝试获取用户的 socket, 如果获取到, 则使用这个 socket 向房间中发送传递过来的消息

这里重新生成了双方的 chatroom-id 然后使用 to() 方法送达了指定的 chatroom, 并向前端的 socket 发送 message:received 事件, 携带了消息

需要注意的是, 这里使用到的是 Socket.IO 本身的特性, 具体来说就是

在 socket.io 中,当一个 socket 加入了某个房间(通过 join(room) 方法),如果该 socket 使用 socket.to(room).emit(event, data) 向这个房间广播消息,这个消息会发送给除了发送者之外的所有已加入该房间的其他 socket

  1. 如果使用 socket.emit(event, data), 消息只会发送给当前连接的客户端(即自身)

  2. 如果使用 socket.to(room).emit(event, data) 或者 io.to(room).emit(event, data),消息会广播给加入了该房间的其他 socket,但不包括发送消息的这个 socket

所以,假如 socket A 加入了房间 room1,它发出 socket.to('room1').emit('someEvent', someData),那么房间 room1 中除了 A 以外的其他 socket(比如 B 和 C)都会收到 someEvent 事件,而 A 自己不会收到这个事件

想知道更多直接去 socket.io 官网

另一个用户收到消息

根据上面的分析, 另一个用户肯定会收到消息, 我们已经在前端监听了 receivedMessage 这个 event

TypeScript
  socket.on('message:received', (msg: Message) => {
    if (msg.receiverUid === currentUserUid && msg.sender.uid === uid) {
      messages.value.push(msg)
      replaceAsideItem(msg)
      nextTick(() => {
        scrollToBottom()
      })
    }
  })

注意, 根据上面的分析, 刚才发送的 message:received 这个 event, 只会被除了发送这个消息的用户之外的其它用户收到

当收到这个 event 之后, 我们将这个 event 携带的, 刚才另一个用户发送过来的 message, push 给当前用户已有的消息数组中, 并将当前用户的 message model 滚动, 然后就会出现一个神奇的效果

评论区

请先 登录 后发表评论