WebSocket

发表于 2025-10-12 18:55 3798 字 19 min read

暂无目录
从零构建WebSocket实时聊天室: 原理剖析与代码实战

作为一名前端工程师,实时通信是我们必须掌握的核心技术之一。今天,我将通过一个完整的聊天室项目,带大家深入理解 WebSocket 的工作原理,并手把手教大家如何构建一个功能完善的实时聊天应用。

一、WebSocket 协议深度解析

1.1 HTTP 协议的局限性与 WebSocket 的诞生

在深入代码之前,我们必须理解为什么需要 WebSocket。传统的 HTTP 协议基于”请求-响应”模型,这种模型存在以下关键问题:

HTTP 轮询的本质缺陷:

// 传统轮询实现
setInterval(() => {
    fetch('/api/messages')
        .then(res => res.json())
        .then(data => {
            // 即使没有新消息,也会发送请求
            // 造成大量带宽浪费和服务器压力
        })
}, 1000)  // 每秒请求一次

这种方式会产生大量无效请求,假设有 1000 个用户在线,每秒就会产生 1000 个请求,而其中可能 99%都是无效的。

长轮询的改进与不足:

// 长轮询实现
function longPolling() {
    fetch('/api/messages', {
        timeout: 30000  // 30秒超时
    }).then(res => {
        // 收到消息或超时后立即发起下一次请求
        longPolling()
    })
}

长轮询虽然减少了请求次数,但服务器需要保持大量连接,且仍然无法实现服务器主动推送。

1.2 WebSocket 握手机制详解

WebSocket 通过一次 HTTP 握手升级连接,随后建立持久的双向通道。让我们深入理解这个过程:

客户端发起握手请求:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket                    # 请求协议升级
Connection: Upgrade                   # 连接升级
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==  # 随机生成的Base64编码密钥
Sec-WebSocket-Version: 13             # WebSocket协议版本
Origin: http://example.com            # 请求来源

关键字段解析:

  • Upgrade: websocket:告诉服务器客户端希望升级到 WebSocket 协议
  • Sec-WebSocket-Key:用于安全校验,防止恶意或无意的连接
  • Sec-WebSocket-Version:确保客户端和服务器使用相同的协议版本

服务器响应握手:

HTTP/1.1 101 Switching Protocols      # 101状态码表示协议切换
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=  # 基于客户端Key计算的值

1.3 WebSocket 数据帧结构

WebSocket 使用帧(Frame)来传输数据,每个帧都有特定的结构:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

重要字段说明:

  • FIN:表示是否为消息的最后一个分片
  • opcode:操作码,表示帧类型(文本、二进制、关闭等)
  • MASK:客户端发送的数据必须掩码,服务器发送的不需要
  • Payload:实际传输的数据

1.4 WebSocket 心跳机制

为了保持连接活跃,WebSocket 实现了 Ping/Pong 心跳机制:

// 客户端心跳实现
let heartbeatInterval;
function startHeartbeat(socket) {
    heartbeatInterval = setInterval(() => {
        if (socket.readyState === WebSocket.OPEN) {
            socket.send(JSON.stringify({ type: 'ping' }));
        }
    }, 30000);  // 每30秒发送一次心跳
}

// 服务端响应
socket.on('message', (data) => {
    const message = JSON.parse(data);
    if (message.type === 'ping') {
        socket.send(JSON.stringify({ type: 'pong' }));
    }
});

二、Socket.IO:工程化的 WebSocket 解决方案

当我们谈论实时 Web 应用时,原生 WebSocket 是基石,但它更像是一个低级别的 API,留下了许多工程化问题需要开发者自己解决,例如连接的可靠性、浏览器兼容性以及扩展性。Socket.IO 正是为解决这些痛点而生,它不是简单地封装 WebSocket,而是提供了一套完整的、经过生产环境严苛考验的实时通信框架。

2.1 为什么选择 Socket.IO?—— 超越基础连接

选择 Socket.IO 的核心理由在于它为开发者“包办”了那些构建健壮实时应用时最棘手、最繁琐的工作。

1. 自动降级机制 (Automatic Fallback): 这是 Socket.IO 最著名的特性。它确保了您的应用能在任何网络环境下工作,即使在不支持 WebSocket 的旧版浏览器或被企业防火墙限制的网络中。

// Socket.IO 会按以下顺序智能地选择最佳传输方式
const transports = [
    'websocket',        // 首选:最高效的双向通信
    'webtransport',     // 实验性:基于 HTTP/3 的新一代传输协议
    'polling'           // 降级方案:可靠的 HTTP 长轮询
];
// Flash Socket 已被废弃,不再使用

深度思考:这个机制的价值在于可靠性。当用户的网络环境(例如公司防火墙封锁了 WebSocket 端口)阻止了 WebSocket 连接时,应用不会直接失败,而是无缝降级到 HTTP 长轮询,保证了核心功能的可用性。

2. 自动重连机制 (Automatic Reconnection): 网络是不可靠的。移动设备进出隧道、Wi-Fi 切换、短暂的网络波动都可能导致连接中断。Socket.IO 内置了一套强大的自动重连逻辑,采用了**“指数退避 (Exponential Backoff)”**策略。

// Socket.IO 默认的智能重连配置
const socket = io({
    reconnection: true,              // 启用自动重连(默认)
    reconnectionAttempts: Infinity,  // 无限次尝试
    reconnectionDelay: 1000,         // 初始延迟1秒
    reconnectionDelayMax: 5000,      // 最大延迟5秒
    randomizationFactor: 0.5         // 随机因子,避免"惊群效应"
});

“指数退避”和“随机因子”是工程化的体现。它避免了在服务器宕机恢复后,成千上万的客户端在同一时刻发起重连请求,从而压垮服务器。

3. 内置心跳机制 (Built-in Heartbeat): 原生 WebSocket 需要开发者手动实现心跳来防止连接被中间代理超时断开。Socket.IO 则内置了心跳机制,通过周期性的 ping/pong 包自动维持连接活性,并检测“僵尸连接”。

4. 房间和命名空间 (Rooms & Namespaces): 这是 Socket.IO 在服务器端扩展性的关键。

  • 命名空间 (Namespaces):像 API 的不同端点(/users, /orders),用于在单个连接上隔离不同的业务逻辑。
  • 房间 (Rooms):在命名空间内,用于向特定的客户端子集广播消息(如群聊、订阅特定频道)。
// 服务器端 (Node.js)
// 命名空间:隔离不同的功能模块
const chatNamespace = io.of('/chat');
const adminNamespace = io.of('/admin');

chatNamespace.on('connection', (socket) => {
    // 房间:实现群组功能
    socket.join('room1');
    // 向 'room1' 房间内的所有客户端(包括发送者)发送消息
    chatNamespace.to('room1').emit('message', 'A new user has joined Room1');
});

5. 消息确认 (Acknowledgements): 支持 emit 时附加回调函数,实现类似 RPC (远程过程调用) 的请求-响应模式,方便地确认消息是否被对方收到并处理。

实现效果 :

在这里插入图片描述
在这里插入图片描述

三、客户端核心实现详解(chat.js)

现在让我们深入分析客户端的核心代码实现。

3.1 连接建立与初始化

const socket = io.connect()

这行代码背后发生了什么?

1. 协议自动识别:

// Socket.IO内部实现
function connect() {
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
    const url = `${protocol}//${window.location.host}`;
    return new WebSocket(url);
}

2. 连接状态管理:

// Socket.IO维护的连接状态
socket.on('connect', () => {
    console.log('Connected:', socket.id);  // 唯一的socket ID
});

socket.on('disconnect', (reason) => {
    console.log('Disconnected:', reason);
    // 可能的原因:'io server disconnect', 'ping timeout', 'transport close'
});

3.2 登录流程的完整实现

page.onLogin = function(username){
    console.log(username)
    socket.emit('login', username)
}

socket.on("login",(result)=>{
    if(result){
        page.intoChatRoom()
        socket.emit('users','')  // 请求用户列表
    }else{
        alert('用户名已被占用')
    }
})

登录流程时序图:

客户端                     服务器
  |                          |
  |----emit('login')-------->|
  |                          | 验证用户名
  |<---emit('login',true)----|
  |                          |
  |----emit('users')-------->|
  |                          | 发送用户列表
  |<---emit('users',[...])---|

关键设计点:

  1. 用户名验证时机: 在服务器端进行验证,确保数据一致性
  2. 异步处理: 使用事件机制处理异步响应,不阻塞 UI
  3. 状态同步: 登录成功后立即获取用户列表,保持状态一致

3.3 消息发送机制深度分析

page.onSendMsg = function(from, msg, to){
    // 输入验证层
    if(!msg || !msg.trim()) return
    // 构造消息体
    const payload = {
        from,           // 发送者
        msg,            // 消息内容
        to: to || null  // 接收者,null表示群发
    }
    // 发送消息
    socket.emit('msg', payload)
    // 乐观更新UI
    page.addMsg(from, msg, to)
    page.clearInput()
}

3.4 消息接收与去重机制

socket.on('message', (data)=>{
    const { from, msg, to } = data || {}
    // 数据验证
    if(!from || !msg) return
    // 去重处理:避免显示自己发送的消息
    if(from === page.me) return
    // 渲染消息
    page.addMsg(from, msg, to)
})

3.5 用户状态实时同步

// 监听新用户加入
socket.on('userin', (user) => {
    page.addUser(user)
})
// 监听用户离开
socket.on('userout', (user) => {
    page.removeUser(user)
})
// 获取完整用户列表
socket.on('users', (users) => {
    for (const u of users) {
        page.addUser(u)
    }
})

3.6 总代码


// 连接到服务器,需要指定完整的服务器地址
const socket = io.connect('http://localhost:3000')

//客户端发送消息给服务器
page.onLogin = function(username){
  console.log(username)
  socket.emit('login', username)
}

page.onSendMsg = function(me,msg,to){
  console.log(msg)
  socket.emit('msg', {
    to,
    content: msg
  })
  page.addMsg(me,msg,to)
}

// 客户端监听服务器消息
socket.on("login",(result)=>{
  if(result){
    page.intoChatRoom()
    socket.emit('users','')
  }else{
    alert('用户名已被占用')
  }
})

// 客户端监听服务器发送的用户列表
socket.on('users', (users) => {
  for (const u of users) {
    page.addUser(u)
  }
})

// 发送消息:由 UI 回调触发
page.onSendMsg = function(from, msg, to){
  if(!msg || !msg.trim()) return
  const payload = { from, msg, to: to || null }
  socket.emit('msg', payload)
  // 本地先渲染自己的消息
  page.addMsg(from, msg, to)
  page.clearInput()
}

// 接收消息:来自其他用户或服务端转发
socket.on('message', (data)=>{
  const { from, msg, to } = data || {}
  // 忽略自己发送且已本地渲染的消息(服务端未回发给自己则无需判断)
  if(!from || !msg) return
  if(from === page.me) return
  page.addMsg(from, msg, to)
})

socket.on('userin', (user) => {
  page.addUser(user)
})

socket.on('userout', (user) => {
  page.removeUser(user)
})

四、服务端架构详解(chatSer.js)

4.1 模块化设计与数据结构

const users = []
module.exports = function (server) {
    const io = require('socket.io')(server);
    io.on('connection', (socket) => {
        // 连接处理逻辑
    })
}

4.2 连接生命周期管理

io.on('connection', (socket) => {
    console.log('New connection:', socket.id)
    // 连接建立时的初始化
    socket.data = {
        authenticated: false,
        username: null
    }
    // 设置超时断开
    const timeout = setTimeout(() => {
        if (!socket.data.authenticated) {
            socket.disconnect()
        }
    }, 10000)  // 10秒内必须完成登录
    socket.on('disconnect', () => {
        clearTimeout(timeout)
        // 清理用户数据
        const idx = users.findIndex(u => u.socket.id === socket.id)
        if (idx !== -1) {
            const [removed] = users.splice(idx, 1)
            socket.broadcast.emit('userout', removed.username)
        }
    })
})

连接管理的最佳实践:

  1. 认证超时: 防止恶意连接占用资源
  2. 资源清理: 确保断开时释放所有资源
  3. 优雅断开: 通知其他用户状态变化

4.3 登录验证与安全性

socket.on("login", (data) => {
    // 基础验证
    if (data === '所有人' || users.filter(u => u.username === data).length > 0) {
        socket.emit("login", false)
        return
    }
    // 注册用户
    users.push({
        username: data,
        socket
    })
    socket.emit('login', true)
    socket.broadcast.emit('userin', data)
})

4.4 消息路由系统的实现

socket.on('msg',(data)=>{
    const { from, to, msg } = data || {}
    if(!from || !msg) return
    if(!to){
        // 群发消息
        socket.broadcast.emit('message', { from, msg })
        return
    }
    // 私聊消息
    const target = users.find(u => u.username === to)
    if(target){
        target.socket.emit('message', { from, msg, to })
    }
})

消息路由的核心概念:

  1. broadcast: 向除发送者外的所有连接发送
  2. to: 向特定房间发送
  3. emit: 向特定 socket 发送

4.5 用户列表同步机制

socket.on('users', () => {
    const arr = users.map(u => u.username)
    socket.emit('users', arr)
})

4.6 总代码


const users = []
module.exports = function (server) {
  const io = require('socket.io')(server, {
    cors: {
      origin: "*",  // 允许所有来源,包括file://
      methods: ["GET", "POST"]
    }
  });

  io.on('connection', (socket) => {
    socket.on("login", (data) => {
      let curusername = ''
      if (data === '所有人' || users.filter(u => u.username === data).length > 0) {
        socket.emit("login", false)
      }
      else {
        users.push({
          username: data,
          socket
        })
        curusername = data
        socket.emit('login', true)
        //新用户进入
        socket.broadcast.emit('userin', data)
      }
    })
    // 用户
    socket.on('users', () => {
      const arr = users.map(u => u.username)
      socket.emit('users', arr)
    })

    // 消息路由
    socket.on('msg',(data)=>{
      const { from, to, msg } = data || {}
      if(!from || !msg) return
      if(!to){
        socket.broadcast.emit('message', { from, msg })
        return
      }
      const target = users.find(u => u.username === to)
      if(target){
        target.socket.emit('message', { from, msg, to })
      }
    })

    socket.on('disconnect', () => {
      const idx = users.findIndex(u => u.socket.id === socket.id)
      if (idx !== -1) {
        const [removed] = users.splice(idx, 1)
        // 通知其他客户端有人离开(如果前端监听的话)
        socket.broadcast.emit('userout', removed.username)
      }
    })
  });
}

五、UI 层架构设计(ui.js)

5.1 模块化封装模式

const page = (function () {
    // 私有变量
    const userList = $(".users");
    const chatList = $(".chat-list");
    // 私有方法
    function formatTime(timestamp) {
        const date = new Date(timestamp)
        return `${date.getHours()}:${date.getMinutes()}`
    }
    // 公开接口
    return {
        me: null,
        intoChatRoom,
        addUser,
        addMsg,
        // ...
    }
})();

IIFE 模式的优势:

  1. 避免全局污染: 所有变量都在函数作用域内
  2. 信息隐藏: 只暴露必要的接口
  3. 单例模式: 确保只有一个实例

5.2 DOM 操作优化

function addMsg(from, msg, to) {
    // 创建消息元素
    const li = $("<li>")
        .html(`<span class="user">${from}</span> <span class="gray">对</span>
    <span class="user">${to ? to : "所有人"}</span> <span class="gray">说:</span> `);
    // 防XSS:使用text()而非html()
    const msgSpan = $("<span>").text(msg);
    li.append(msgSpan).appendTo(chatList);
    // 自动滚动到底部
    chatList.scrollTop(chatList.prop("scrollHeight"), 0);
}

5.3 事件处理与用户交互

// 事件委托处理用户列表点击
userList.click((e) => {
    if (e.target.tagName === "LI") {
        $(".sendmsg .user").text(e.target.innerText);
    }
});
  
// 回车发送消息
$(".sendmsg input").keydown((e) => {
    if (e.key === "Enter") {
        page.onSendMsg &&
            page.onSendMsg(page.me, $(e.target).val(), page.getTargetUser());
    }
});

5.4 总代码

const page = (function () {
  const userList = $(".users");
  const chatList = $(".chat-list");
  function intoChatRoom() {
    $(".login").hide();
    $(".chat").show();
  }

  function addUser(userName) {
    $("<li>").text(userName).attr("user", userName).appendTo(userList);
    const number = +$(".user-list .title span").text();
    $(".user-list .title span").text(number + 1);
    addLog(`<span class="user">${userName}</span> 进入聊天室`);
    chatList.scrollTop(chatList.prop("scrollHeight"), 0);
  }

  function addLog(log) {
    $("<li>").addClass("log").html(log).appendTo(chatList);
  }

  function removeUser(userName) {
    const li = userList.find(`li[user="${userName}"`);
    if (!li.length) {
      return;
    }
    li.remove();
    const number = +$(".user-list .title span").text();
    $(".user-list .title span").text(number - 1);
    addLog(`<span class="user">${userName}</span> 退出了聊天室`);
  }

  function clearInput() {
    $(".sendmsg input").val("");
  }

  function addMsg(from, msg, to) {
    const li = $("<li>")
      .html(`<span class="user">${from}</span> <span class="gray">对</span> 
    <span class="user">${
      to ? to : "所有人"
    }</span> <span class="gray">说:</span> `);
    const msgSpan = $("<span>").text(msg);
    li.append(msgSpan).appendTo(chatList);
    chatList.scrollTop(chatList.prop("scrollHeight"), 0);
  }

  function getTargetUser() {
    const user = $(".sendmsg .user").text();
    return user === "所有人" ? null : user;
  }

  function initChatRoom() {
    userList.html(`<li class="all">所有人</li>`);
    $(".user-list .title span").text(0);
    chatList.html(`<li class="log">欢迎来到渡一聊天室</li>`);
  }

  userList.click((e) => {
    if (e.target.tagName === "LI") {
      $(".sendmsg .user").text(e.target.innerText);
    }
  });

  return {
    me: null,
    intoChatRoom,
    initChatRoom,
    addUser,
    addLog,
    removeUser,
    addMsg,
    clearInput,
    getTargetUser,
    onLogin: null,
    onSendMsg: null,
  };
})();

(function () {
  $(".login input").keydown((e) => {
    if (e.key === "Enter") {
      page.me = $(e.target).val();
      page.onLogin && page.onLogin(page.me);
    }
  });
  $(".sendmsg input").keydown((e) => {
    if (e.key === "Enter") {
      page.onSendMsg &&
        page.onSendMsg(page.me, $(e.target).val(), page.getTargetUser());
    }
  });
})();

HTML 实现

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>在线聊天室</title>
  <link rel="stylesheet" href="./css/global.css" />
  <link rel="stylesheet" href="./css/index.css" />
</head>

<body>
  <div class="login">
    <div class="form">
      <h1>请输入你的昵称,回车后进入聊天室</h1>
      <input type="text" placeholder="请输入你的昵称"/>
    </div>
  </div>
  <div class="chat" style="display: none;">
    <div class="user-list">
      <div class="title">在线人数:<span>0</span></div>
      <ul class="users">
        <li class="all">所有人</li>
      </ul>
    </div>

    <div class="main">
      <ul class="chat-list">
        <li class="log">欢迎来到聊天室</li>
      </ul>
      <div class="sendmsg">
        <span class="gray">对</span>
        <span class="user">所有人</span>
        <span class="gray">说:</span>
        <input type="text" />
      </div>
    </div>
  </div>
  <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.0/jquery.min.js"></script>
  <script src="js/ui.js"></script>
  <!-- Socket.IO客户端库 -->
  <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
  <script src="./js/chat.js"></script>
</body>

</html>