轻量级实时通信:用 SSE 构建局域网聊天室(Vue3 + Node)

669 阅读3分钟

🌐 实现一个局域网聊天室(支持公网)

前言

这篇文章将介绍如何实现一个简单的局域网聊天室(当然也支持部署到公网)。后端使用 Express + MongoDB,前端采用 Vue 3

由于是聊天系统,我们需要建立一个长连接。目前较常见的两种方式是:

  • WebSocket:双向通信,适用于对实时性要求较高的应用。
  • SSE(Server-Sent Events):单向通信,适用于服务器向客户端推送数据的场景。

本文将专注于对比和实现 SSE 的方式。

💡 注:SSE 的优势在于实现简单,基于 HTTP 协议,天然支持断线重连和浏览器兼容(除 IE)。

user-face.png

需求分析

我们的核心需求非常简单:

  1. 发送消息
  2. 获取历史消息

除此之外,我们还需要一个用于建立 SSE 长连接的接口,用来让客户端接收其他用户发送的消息:

  • 发送消息接口
  • 获取历史消息接口
  • SSE 接口(用于建立长连接)

代码实现部分

后端

我们使用 Express 搭建服务器,并使用 MongoDB 存储聊天记录。

接口逻辑

const express = require('express')
const cors = require('cors')

const mongoose = require('mongoose')
const Message = require('./models/Message')

const app = express()
app.use(cors())
app.use(express.json())

mongoose.connect('mongodb://127.0.0.1:27017/chat-sse-demo', {
  useNewUrlParser: true,
  useUnifiedTopology: true,
})
mongoose.connection.once('open', () => {
  console.log('✅ MongoDB 已连接')
})

let clients = [] // 所有连接的 SSE 客户端

// SSE 接口
app.get('/api/chat/stream', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  res.flushHeaders()

  const clientId = Date.now()
  const newClient = {
    id: clientId,
    res,
  }
  clients.push(newClient)

  // client 断开连接时,移除该客户端 (客户端关闭网页,刷新页面,网络异常断开
  // 避免内存泄漏
  req.on('close', () => {
    clients = clients.filter(client => client.id !== clientId)
  })
})

// 心跳机制
// 每 15 秒向所有客户端发送一个空消息,保持连接活跃
setInterval(() => {
  clients.forEach(client => {
    client.res.write(':\n\n') // SSE 心跳格式
  })
}, 15000)

// 接收用户输入的接口
app.post('/api/chat/message', async (req, res) => {
  const { message, userId, timestamp } = req.body
  if (!message || !userId) {
    return res.status(400).json({ error: '缺少 用户ID 或 消息' })
  }

  // 存储用户消息
  await Message.create({ message, userId, timestamp })

  // 向所有客户端广播整条消息
  const payload = JSON.stringify({ message, userId, timestamp })
  clients.forEach(client => {
    client.res.write(`data: ${payload}\n\n`)
  })

  return res.json({ status: 'ok', message: '消息已接收' })
})

app.get('/api/chat/history', async (req, res) => {
  const messages = await Message.find().sort({ timestamp: 1 })
  res.json(messages)
})

const PORT = 3000
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`)
})

小结

  • /api/chat/stream:建立 SSE 长连接
  • /api/chat/message:发送消息并广播
  • /api/chat/history:获取历史记录

前端实现(Vue 3)

前端实现了一个简洁的聊天界面,并展示在线用户。关键逻辑如下:

onMounted(() => {
  getChatHistory()
  startStream()
})
const startStream = () => {
  eventSource = new EventSource('/api/chat/stream')

  eventSource.onmessage = (event: MessageEvent) => {
    const data = JSON.parse(event.data)
    if (data.userId !== myId) {
      chatMessages.value.push(data)
      addUserIfNotExists(data.userId)
      scrollToBottom()
    }
  }

  eventSource.onerror = () => {
    console.error('SSE 连接出错,尝试重连...')
    eventSource?.close()
    setTimeout(() => {
      startStream()
    }, 1000)
  }
}

const getChatHistory = async () => {
  const response = await fetch('/api/chat/history')
  const data = await response.json()
  chatMessages.value = data.map((msg) => {
    addUserIfNotExists(msg.userId)
    return msg
  })
  scrollToBottom()
}

const sendMessage = async () => {
  if (!input.value.trim()) return

  try {
    // 本地显示
    const timestamp = new Date().toISOString()
    chatMessages.value.push({ message: input.value, userId: myId, timestamp })
    addUserIfNotExists(myId)
    scrollToBottom()

    await fetch('/api/chat/message', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ message: input.value, userId: myId, timestamp }),
    })
  } catch (error) {
    console.error('发送消息失败:', error)
  }

  input.value = ''
}

小结

  • 页面加载后会:

    • 拉取历史消息
    • 建立 SSE 长连接
  • 接收新消息时:

    • 添加到消息列表
    • 自动滚动到底部
  • 发送消息时:

    • 先本地展示
    • 再发送给后端广播

⚠️ 当前 SSE 是单向通信,因此本地用户的消息需要前端手动显示。

🔗 项目地址

👉 代码都在 develop 分支中