首页 / Node.js 教程 / WebSocket 实时应用

Node.js 教程

WebSocket 实时应用

本教程共 76 篇 · 第 73 篇 · 更新于 2026-07-25 · 约 7 分钟阅读

Node.jsWebSocket实时聊天室ws

73. WebSocket 实时应用

本节目标:用 ws 构建聊天室,广播、心跳和 Socket.IO 对比。

HTTP 是「请求-响应」模式:客户端问,服务器答,对话结束。如果想做聊天室、股票行情、协同编辑,HTTP 就很别扭了——要么客户端不断轮询(浪费带宽),要么服务器没法主动推数据。WebSocket 解决了这个问题:它在客户端和服务器之间建立一条持久连接,双方随时都能发消息。

Node.js 生态里有两个主流选择:ws 库轻量纯粹,只实现 WebSocket 协议;Socket.IO 功能更丰富,带自动重连、房间、命名空间等高级特性。这节我们先用 ws 搭一个完整聊天室,再对比 Socket.IO 的适用场景。

WebSocket 握手

WebSocket 连接不是凭空建立的。它先走一次 HTTP 请求,服务器返回 101 Switching Protocols,之后连接从 HTTP 升级为 WebSocket。这个过程叫「握手」。

Client                    Server
  | ---- HTTP GET --------> |
  |  Upgrade: websocket     |
  | <--- 101 Switching ---  |
  |       Protocols         |
  | <=== WebSocket =========> |
  |    双向数据帧传输       |

一旦握手成功,后续通信就不再需要 HTTP 头,数据帧非常轻量。

用 ws 搭建聊天室

安装依赖:

npm install ws express

服务端(server.js)

import express from 'express'
import { WebSocketServer } from 'ws'
import http from 'http'
import path from 'path'

const app = express()
const server = http.createServer(app)

// 托管静态文件和聊天页面
app.use(express.static('public'))

const wss = new WebSocketServer({ server })

// 存储连接和用户名
const clients = new Map()

function broadcast(data, excludeWs = null) {
  const message = JSON.stringify(data)
  wss.clients.forEach(ws => {
    if (ws !== excludeWs && ws.readyState === 1) {
      ws.send(message)
    }
  })
}

wss.on('connection', (ws) => {
  let username = null

  ws.on('message', (raw) => {
    let msg
    try {
      msg = JSON.parse(raw)
    } catch {
      return
    }

    if (msg.type === 'join') {
      username = msg.username || 'Anonymous'
      clients.set(ws, username)

      ws.send(JSON.stringify({
        type: 'system',
        text: `Welcome, ${username}!`
      }))

      broadcast({
        type: 'system',
        text: `${username} joined the chat`
      }, ws)
    }

    if (msg.type === 'chat' && username) {
      broadcast({
        type: 'chat',
        username,
        text: msg.text,
        time: new Date().toLocaleTimeString()
      })
    }
  })

  ws.on('close', () => {
    const name = clients.get(ws)
    clients.delete(ws)
    if (name) {
      broadcast({
        type: 'system',
        text: `${name} left the chat`
      })
    }
  })

  ws.on('error', console.error)
})

// 心跳:每 30 秒 ping 一次,清理死连接
const interval = setInterval(() => {
  wss.clients.forEach(ws => {
    if (!ws.isAlive) {
      return ws.terminate()
    }
    ws.isAlive = false
    ws.ping()
  })
}, 30000)

wss.on('close', () => clearInterval(interval))

server.listen(3000, () => {
  console.log('Chat server at http://localhost:3000')
})

客户端(public/index.html)

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>WebSocket Chat</title>
  <style>
    body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
    #messages { border: 1px solid #ddd; height: 400px; overflow-y: auto; padding: 10px; margin-bottom: 10px; }
    .msg { margin: 6px 0; }
    .system { color: #888; font-style: italic; }
    .chat .name { font-weight: bold; color: #333; }
    .chat .time { color: #999; font-size: 12px; margin-left: 8px; }
    #form { display: flex; gap: 8px; }
    input { flex: 1; padding: 10px; font-size: 16px; }
    button { padding: 10px 20px; font-size: 16px; cursor: pointer; }
    #login { margin-bottom: 20px; }
    #chat { display: none; }
  </style>
</head>
<body>
  <div id="login">
    <h2>Enter your name</h2>
    <input id="username" placeholder="Your name" value="Tom">
    <button onclick="join()">Join</button>
  </div>

  <div id="chat">
    <div id="messages"></div>
    <form id="form" onsubmit="send(event)">
      <input id="input" placeholder="Type a message..." autocomplete="off">
      <button>Send</button>
    </form>
  </div>

  <script>
    let ws

    function join() {
      const name = document.getElementById('username').value.trim() || 'Anonymous'
      document.getElementById('login').style.display = 'none'
      document.getElementById('chat').style.display = 'block'

      const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
      ws = new WebSocket(`${protocol}//${location.host}`)

      ws.onopen = () => {
        ws.send(JSON.stringify({ type: 'join', username: name }))
      }

      ws.onmessage = (event) => {
        const msg = JSON.parse(event.data)
        const div = document.createElement('div')
        div.className = 'msg ' + msg.type

        if (msg.type === 'system') {
          div.textContent = msg.text
        } else {
          div.innerHTML = `<span class="name">${msg.username}</span>` +
            `<span class="time">${msg.time}</span>` +
            `<div>${escapeHtml(msg.text)}</div>`
        }

        const box = document.getElementById('messages')
        box.appendChild(div)
        box.scrollTop = box.scrollHeight
      }

      ws.onclose = () => {
        appendSystem('Disconnected from server')
      }
    }

    function send(e) {
      e.preventDefault()
      const input = document.getElementById('input')
      const text = input.value.trim()
      if (!text || !ws) return
      ws.send(JSON.stringify({ type: 'chat', text }))
      input.value = ''
    }

    function appendSystem(text) {
      const div = document.createElement('div')
      div.className = 'msg system'
      div.textContent = text
      document.getElementById('messages').appendChild(div)
    }

    function escapeHtml(str) {
      return str.replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))
    }
  </script>
</body>
</html>

创建 public/ 目录,把 index.html 放进去,然后 node server.js。打开多个浏览器标签页,输入名字加入聊天,消息会实时同步到所有客户端。

代码要点拆解

1. 广播(Broadcast)

ws 库没有内置的 broadcast 方法,需要遍历 wss.clients 自己实现。注意过滤掉发送者(可选)和未就绪的连接:

wss.clients.forEach(ws => {
  if (ws.readyState === 1) {   // 1 = OPEN
    ws.send(message)
  }
})

2. 心跳(Heartbeat)

WebSocket 连接可能因网络波动「假死」——客户端以为还连着,其实服务器早就收不到了。心跳机制就是定时发 ping 帧,如果对方不回复 pong,就断开连接。

ws.isAlive = true
ws.on('pong', () => { ws.isAlive = true })

// 定时检查
if (!ws.isAlive) ws.terminate()
ws.isAlive = false
ws.ping()

3. 消息协议

裸字符串做通信协议容易乱,建议统一用 JSON,每个消息带 type 字段:

{ "type": "join", "username": "Tom" }
{ "type": "chat", "text": "Hello everyone" }
{ "type": "system", "text": "Tom joined the chat" }

什么时候选 Socket.IO

ws 轻量纯粹,但功能也基础。如果你的需求更复杂,Socket.IO 能省不少事:

  • 自动降级:浏览器不支持 WebSocket 时,自动切到 HTTP 长轮询
  • 自动重连:断线后自动尝试恢复连接
  • 房间(Room):用 socket.join('room1') 就能做群聊,不需要自己维护映射表
  • 命名空间(Namespace):同一个服务器跑多套逻辑隔离的 WebSocket 服务
import { Server } from 'socket.io'
import { createServer } from 'http'

const httpServer = createServer()
const io = new Server(httpServer)

io.on('connection', (socket) => {
  socket.join('lobby')
  io.to('lobby').emit('message', 'Someone joined')
})

httpServer.listen(3000)

Socket.IO 的代价是协议 overhead 更大,而且客户端必须加载 Socket.IO 的库,不能直接用浏览器原生 WebSocket。如果你的场景是内部系统、现代浏览器可控,原生 ws 更干净;如果需要兼容旧浏览器或想要开箱即用的房间/重连功能,Socket.IO 更合适。

安全提醒

WebSocket 不遵循浏览器的同源策略(CORS),握手阶段的 HTTP 请求会带 Cookie,但后续数据帧不会自动受 CORS 保护。你需要在连接建立时做身份校验:

wss.on('connection', (ws, req) => {
  const token = new URL(req.url, 'http://localhost').searchParams.get('token')
  if (!verifyToken(token)) {
    ws.close(1008, 'Invalid token')
    return
  }
})

另外,千万别信任客户端发来的任何数据。聊天室示例里的 escapeHtml 就是为了防止 XSS——如果直接把用户输入的 HTML 插进页面,攻击者可以注入 <script> 偷走 Cookie。