Skip to content

玄学放置 · 公屏聊天接入 WebSocket

起因

公屏聊天从第 6 篇就有了,但一直是纯前端 mock——chat store 用 setTimeout 按随机间隔推送假消息,用户发送的内容也只存在本地 messages 数组里,刷新就没了。两个浏览器标签页之间完全隔离,看不到彼此的消息。

后端在第 11 篇搭建时已经注册了 @fastify/websocket 插件,/ws 路由也写了心跳逻辑,但聊天功能一直没接上。这次要把 mock 替换成真实的 WebSocket 通信,让所有在线玩家能实时看到彼此的消息。

关键决策:不入库,内存缓冲

放置游戏的公屏聊天本质上是氛围功能——玩家偶尔冒泡聊两句,不需要像 IM 那样保证消息不丢。对于这种场景有两种常见做法:

  • 入库 + TTL:消息写入数据库或 Redis,保留最近 24 小时或 200 条,定期清理
  • 纯内存:服务端维护一个固定长度的数组,新连接时推送历史,重启即清空

选了纯内存方案。理由是这个游戏是个人项目,不需要消息持久化的可靠性保证;内存方案零依赖、零运维,代码量也最小。唯一的代价是服务端重启后历史消息丢失——对公屏闲聊来说完全可以接受。

消息协议设计

WebSocket 通信用 JSON 文本帧,协议很简单:

客户端 → 服务端:
  { type: 'chat', content: '消息内容' }
  { type: 'ping' }

服务端 → 客户端:
  { type: 'history', messages: [...] }   // 连接时推送历史
  { type: 'chat', message: {...} }       // 广播新消息
  { type: 'online', count: 12 }          // 在线人数变化
  { type: 'pong' }

一个设计选择:消息的 idtime 由服务端统一分配,客户端只发 content。这样做的好处是所有客户端看到的消息顺序和时间戳完全一致,不会因为客户端时钟偏差导致消息乱序。代价是发送者看到自己消息会有一个网络往返的延迟——对聊天场景来说几十毫秒的延迟无感知。

实现要点

服务端:环形缓冲 + 广播

聊天服务的核心是一个固定长度的数组,超过 100 条时 shift 掉最早的:

ts
const MAX_HISTORY = 100
const buffer: ChatMessage[] = []

export function addMessage(user: string, content: string): ChatMessage {
  const msg = { id: nextId++, user, content, time: Date.now(), type: 'user' }
  buffer.push(msg)
  if (buffer.length > MAX_HISTORY) buffer.shift()
  return msg
}

WebSocket 连接处理的关键逻辑:连接时从 URL query 提取 token 验证身份(复用已有的 verifyToken),验证通过后加入连接池、推送历史、广播在线人数。收到聊天消息时调用 addMessage 然后广播给所有连接。

ts
app.get('/ws', { websocket: true }, async (socket, req) => {
  const url = new URL(req.url ?? '', `http://${req.headers.host}`)
  const token = url.searchParams.get('token')
  // 验证 token,失败则 close(4001)
  const { username } = await verifyToken(token)

  clients.add({ socket, username })
  socket.send(JSON.stringify({ type: 'history', messages: getHistory() }))
  broadcast({ type: 'online', count: clients.size })
  // ...
})

为什么用 URL query 传 token 而不是在连接后发一条认证消息?因为浏览器的 WebSocket API 不支持自定义 header,只能通过 URL 参数或 cookie 传递认证信息。URL query 最直接,且 token 本身有过期机制,不需要额外的安全措施。

前端:WebSocket 客户端封装

封装了一个独立的 api/ws.ts 模块,职责单一——管理连接生命周期和消息路由:

ts
export function connect(token: string): void {
  ws = new WebSocket(`ws://localhost:3000/ws?token=${encodeURIComponent(token)}`)
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data)
    messageHandler?.(data)
  }
  ws.onclose = () => {
    if (!intentionalClose) scheduleReconnect(token)
  }
}

自动重连用指数退避:首次 1 秒后重试,之后 2s、4s、8s...最大 30 秒。加了随机抖动避免所有客户端同时重连造成惊群效应。intentionalClose 标志区分"用户主动断开"和"网络异常断开"——前者不重连,后者自动恢复。

chat store 重写

旧的 chat store 有 startMockFeedstopMockFeedbootstrapWelcome 这些 mock 方法。重写后只剩三个核心 action:

  • connect() — 从 localStorage 取 token,注册消息回调,建立连接
  • disconnect() — 主动断开
  • sendByUser(content) — 通过 ws 发送,不再本地 push

收到服务端消息时根据 type 分发:history 批量填充、chat 单条追加、online 更新在线人数。保留了 MAX_MESSAGES = 200 的限流逻辑,防止长时间挂机内存膨胀。

注意事项

在线人数的来源变了。 之前 ChatPanel 组件自己从消息列表推算在线人数(统计不同用户名数量),现在改为服务端推送真实连接数。这意味着 ChatPanel 需要新增一个 onlineCount prop,由 HomeView 从 store 传入。

mock/chat.ts 的处理。 这个文件之前导出了 WELCOME_MESSAGESFAKE_USERSpickRandomFakeMessage 等 mock 函数,ChatPanel 组件还 import 了它的 ChatMessage 类型。重写后只保留类型定义,mock 函数全部移除。如果后续想在离线模式下保留 mock 聊天体验,可以再加回来,但目前没有这个需求。

消息长度限制。 服务端对单条消息做了 500 字符的硬限制——超过直接丢弃不广播。这个限制在客户端没有做 UI 提示(textarea 没有 maxlength),但 500 字对公屏闲聊绰绰有余。如果后续要加提示,在 textarea 上加 maxlength="500" 即可。

收获

这次改动让公屏聊天从"自言自语的假象"变成了真实的多人通信。整个实现只涉及一个新的服务端文件(chat service)和一个新的前端文件(ws client),其余都是对已有代码的改造——WebSocket 路由本来就有骨架,chat store 本来就有消息管理逻辑,只是数据源从 mock 换成了网络。

内存缓冲 + WebSocket 广播是轻量实时通信的经典模式,适合所有"不需要持久化、不需要离线消息"的场景。如果后续要加频道(比如学科专属聊天室),只需要把单个 buffer 和 clients Set 改成按频道分组的 Map,协议加一个 channel 字段即可。