搞懂websocket前端的问题

489 阅读18分钟

是什么

WebSocket,是一种网络传输协议,位于OSI模型的应用层。可在单个TCP连接上进行全双工通信,能更好的节省服务器资源和带宽并达到实时通迅

基于一个已经建立的 TCP 连接之上,通过一个特殊的 HTTP 请求来实现“协议升级”。客户端和服务器需要再完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。不需要客户端再去请求数据,服务器可以主动向客户端发送数据。

在websocket出现之前,在 WebSocket 协议出现以前,创建一个和服务端进双通道通信的 web 应用,需要依赖HTTP协议,进行不停的轮询,HTTP 协议有一个缺陷:通信只能由客户端发起,不具备服务器推送能力。

这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。如果轮询的频率比较高,那么就可以近似地实现“实时通信”的效果。轮询的缺点也很明显,反复发送无效查询请求耗费了大量的带宽和 CPU资源

特点

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

WebSocket 的其他特点包括:

(1)建立在 TCP 协议之上,服务器端的实现比较容易。

(2)与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。

(3)数据格式比较轻量,性能开销小,通信高效。

(4)可以发送文本,也可以发送二进制数据。

(5)没有同源限制,客户端可以与任意服务器通信。

(6)协议标识符是ws(如果加密,则为wss),服务器网址就是 URL

优缺点

优点:

  • 更强的实时性:WebSocket 提供了低延迟的实时通信能力,能够在服务器端有新数据时立即推送给客户端
  • 减少网络负载:由于websocket的持久化连接,他减少了http请求的发送次数,减少了网络负载
  • 保持创连接状态:创建通信后,可省略状态信息,不同于HTTP每次请求需要携带身份验证
  • 更高的性能:由于减少了 HTTP 请求的开销,WebSocket 在性能上更高效。
  • 跨域支持:WebSocket 具备跨域通信的能力,可以跨域进行实时通信。

缺点:

  1. WebSocket需要浏览器和服务器端都支持该协议。
  2. WebSocket会增加服务器的负担,不适合大规模连接的应用场景。
  3. 不需要很频繁或仅获取一次的数据可以通过简单的HTTP请求查询,因此在这种情况下最好不要使用WebSocket。

应用场景

  • 弹幕
  • 在线游戏
  • 媒体聊天
  • 协同编辑
  • 基于位置的应用
  • 体育实况更新
  • 股票基金报价实时更新

WebSocket 的连接建立过程是怎样的?

  1. 客户端发起http请求,经过3次握手后,建立起TCP连接;http请求头里存放如:Upgrade、Connection、WebSocket-Version,Sec-WebSocket-Key等;

    Upgrade: websocket 
    Connection: Upgrade //指定协议升级和建立连接
    //告诉服务器“我希望升级到 WebSocket”。
    
    Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13
    

    Sec-WebSocket-Key是一个 Base64 encode 的值,这个是浏览器随机生成的,验证服务器是不是真的是 WebSocket 助理。

    Sec-WebSocket-Version 告诉服务器所使用的协议版本

    Sec-WebSocket-Protocol客户端指定它想要使用的 WebSocket 子协议

    💡

    WebSocket 本身定义了一个通用的通信协议,但它并不规定具体应用层的数据格式或交互逻辑。这意味着,在不同的应用场景下,你可能需要使用不同的协议来解释 WebSocket 传输的数据。

    例如:

    • 在线聊天应用: 可能需要一个处理消息、用户状态、通知等功能的协议。
    • 实时游戏: 可能需要一个传输玩家动作、游戏状态、同步信息的协议。
    • 特定设备控制: 可能需要一个定义设备命令和响应的协议。
  2. 然后,服务器收到客户端的握手请求后,验证请求头的字段(特别是UpgradeConnectionSec-WebSocket-Key)。如果请求符合规范,返回握手响应,响应头包含 Upgrade 和 Connection 字段,以及 Sec-WebSocket-Accept字段

  3. 客户端收到握手响应后,验证返回的Sec-WebSocket-Accept是否正确

  4. 验证通过后,WebSocket 连接建立成功,客户端和服务器基于TCP进行实时全双工通信。

WebSocket 的事件有哪些?请分别描述它们的作用

  • open:当 WebSocket 连接成功建立时触发的事件。可以在此事件中执行初始化操作或向服务器发送初始数据。
  • message:当从服务器接收到新消息时触发的事件。可以在此事件中处理接收到的数据。
  • error:当出现连接错误时触发的事件。错误可能包括连接失败、数据传输错误等。可以在此事件中处理错误并采取适当的措施。
  • close:当 WebSocket 连接关闭时触发的事件。关闭可能是由服务器或客户端发起的,可以在此事件中执行清理操作或重新连接等操作。

websocket readystate属性

-   **0 (CONNECTING)** : 连接还没有建立,或者正在尝试建立连接。这是 WebSocket 连接的初始状态。
-   **1 (OPEN)** : 连接已经建立,并且可以随时发送和接收数据。这是最常见也最活跃的状态。
-   **2 (CLOSING)** : 正在进行关闭连接的握手。当调用 `close()` 方法时,连接会进入这个状态。
-   **3 (CLOSED)** : 连接已经关闭,或者根本没有成功建立。

在浏览器端如何创建和使用 WebSocket 对象?

在浏览器端,可以使用 JavaScript 中的 WebSocket 对象来创建和使用 WebSocket。示例代码如下:

const socket = new WebSocket('wss://example.com/socket');

其中,new WebSocket() 通过传入服务器的 WebSocket URL 来创建一个 WebSocket 对象。然后可以通过设置事件处理函数来处理 WebSocket 的事件,例如:

socket.onopen = function(event) {
  console.log('WebSocket 连接已打开');
};

socket.onmessage = function(event) {
  const message = event.data;
  console.log('接收到消息:', message);
};

socket.onerror = function(error) {
  console.error('WebSocket 错误:', error);
};

socket.onclose = function(event) {
  console.log('WebSocket 连接已关闭');
};

在连接建立成功后,可以使用 send() 方法发送消息到服务器,例如:

socket.send('Hello, server!');

心跳机制

一个健壮的前端 WebSocket 连接管理通常是以下心跳机制和重连机制的结合

前端实现WebSocket心跳机制的方式主要有两种:

  1. 使用setInterval定时发送心跳包。

    • WebSocket心跳包机制

      WebSocket心跳包是WebSocket协议的保活机制,用于维持长连接。有效的心跳包可以防止长时间不通讯时,WebSocket自动断开连接。

      心跳包是指在一定时间间隔内,WebSocket发送的空数据包。常见的WebSocket心跳包机制如下:

      1. 客户端定时向服务器发送心跳数据包,以保持长连接。
      2. 服务器定时向客户端发送心跳数据包,以检测客户端连接是否正常。
      3. 双向发送心跳数据包。
  2. 在前端监听到WebSocket的onclose()事件时,重新创建WebSocket连接。

    • WebSocket重连机制

      WebSocket在发送和接收数据时,可能会因为网络原因、服务器宕机等因素而断开连接,此时需要使用WebSocket重连机制进行重新连接。

      WebSocket重连机制可以通过以下几种方式实现:

      1. 前端监听WebSocket的onclose()事件,重新创建WebSocket连接。
      2. 使用WebSocket插件或库,例如Sockjs、Stompjs等。
      3. 使用心跳机制检测WebSocket连接状态,自动重连。
      4. 使用断线重连插件或库,例如ReconnectingWebSocket等。

第一种方式会对服务器造成很大的压力,因为即使WebSocket连接正常,也要定时发送心跳包,从而消耗服务器资源。第二种方式虽然减轻了服务器的负担,但是在重连时可能会丢失一些数据。

下面是心跳机制具体实现代码: start 函数负责定期(由 timeout 控制)发送“hello”消息。reset 函数在收到服务器确认消息(“收到,hello”)时被调用,用来刷新这个心跳计时器。

当连接成功打开 (onopen) 时,会重置重连次数并启动心跳。当收到特定服务器消息 (status === 408) 时,会触发重连 (reconnect)。

  // 开启心跳
  const start = () => {
    clearTimeout(timeoutObj);
    // serverTimeoutObj && clearTimeout(serverTimeoutObj);
    timeoutObj = setTimeout(function () {
      if (websocketRef.current?.readyState === 1) {
        //连接正常
        sendMessage('hello');
      }
    }, timeout);
  };
  const reset = () => {
    // 重置心跳 清除时间
    clearTimeout(timeoutObj);
    // 重启心跳
    start();
  };
  
  ws.onopen = (event) => {
      onOpenRef.current?.(event, ws);
      reconnectTimesRef.current = 0;
      start(); // 开启心跳
      setReadyState(ws.readyState || ReadyState.Open);
    };
    ws.onmessage = (message: WebSocketEventMap['message']) => {
      const { data } = message;
     
      if (data === '收到,hello') {
        reset();
        return;
      }
      if (JSON.parse(data).status === 408) {
        reconnect();
        return;
      }
      onMessageRef.current?.(message, ws);
      setLatestMessage(message);
    };
 const connect = () => {
    reconnectTimesRef.current = 0;
    connectWs();
  };

具体怎么实现的

  1. 客户端(你提供的代码)会定时地向服务器发送一个特定的消息(20秒),通过setTimeout
  2. 服务器收到后,发送确认响应
  3. 客户端发送心跳消息后,启动定时器,如果预设的超时时间内收到了服务器的响应,就认为连接是正常的,并重置心跳计时器。如果在预设的超时时间内没有收到服务器的响应,就怀疑连接已断开,这时会触发重连机制
  • start() 函数:

    • "在这个 start 函数中,我们使用 setTimeout 来实现定时发送心跳timeout 变量定义了发送心跳的间隔。
    • 在 setTimeout 的回调中,检查 websockObj[name].readyState === 1,看WebSocket 连接是否为 OPEN 状态。如果是 websockObj.send(’ping’)。不是就reconnect()尝试重连
    • 如果状态正常,就发送一个心跳消息’ping’。
  • reset() 函数:

    • "当客户端收到服务器的确认响应(收到 'pong')时,我们会调用 reset() 函数。
    • reset() 的作用是清除之前设置的 setTimeout 计时器,然后立即重新启动一个新的心跳发送周期 (start())。这保证了心跳是持续不断的,并且在连接正常时不会因为之前的计时器触发而产生误判。"
  • reconnect()函数

    重连函数在

    1. 心跳超时(如果定时器触发时(达到 timeout 设定的时间,即 20 秒),检测到 websockObj[name].readyState 不为 1
    2. 连接错误时websockObj[name].onerror 网络断开,或者websockObj[name].onclose:当 WebSocket 连接关闭时,会触发 onclose 事件。如果关闭的代码 e.code 不是 1000 且 e.reason 不是 leavePageClose(这表示非正常关闭,也不是用户主动切换页面导致) 这里的e.code是WebSocket CloseEvent 接口的标准属性,1000表示正常关闭(结合*beforeunload 或 unload 事件**),e.reason是自定义的字符串*

    都会尝试调用reconnect函数重连

    function reconnect(name) {
      // 1. 防止重复重连的锁
      if (wsObjs[name].connectedLock) {
        return; // 如果已经有一个重连过程在进行中,则直接返回,避免多次尝试
      }
      wsObjs[name].connectedLock = true; // 设置锁,表示正在尝试重连
    
      // 2. 设置重连延迟
      // 清除之前的重连定时器,确保只有一个定时器在运行
      wsObjs[name].timeoutnum && clearTimeout(wsObjs[name].timeoutnum);
      // 设置一个新的定时器,在 timeoutnum (5秒) 后执行重连操作
      wsObjs[name].timeoutnum = setTimeout(function () {
        // 3. 重新初始化 WebSocket 连接
        initWebSocket(
          wsObjs[name].name,
          wsObjs[name].wsuri,
          wsObjs[name].sendMsg,
          wsObjs[name].callback
        );
        
        // 4. 获取用户信息,处理token过期情况
        getUserInfo(); 
        
        // 5. 释放锁
        wsObjs[name].connectedLock = false; // 重连尝试结束后,释放锁
      }, timeoutnum); // timeoutnum = 5 * 1000 = 5秒
    }
    
    1. 检测断开: 心跳检测到连接断开,或 onerror/onclose 事件触发。

    2. 调用 reconnect()

    3. 检查重连锁: 如果已锁定,则不进行操作。

    4. 设置重连锁: connectedLock = true

    5. 清除旧定时器,设置新定时器: 延迟 5 秒后执行重连。

    6. 延迟后执行:

      • 调用 initWebSocket 创建并尝试建立新的 WebSocket 连接。
      • 调用 getUserInfo 检查 Token 状态。
      • 释放重连锁:connectedLock = false
    7. 新连接建立后: initWebSocket 会重新开启心跳,继续监控连接状态。

    • 为什么需要延迟?

      • 避免请求过多/服务器压力: 如果连接立即断开就立即重连,在网络不稳定或服务器故障时,客户端可能会快速地、连续地尝试重连,给服务器造成巨大压力,甚至可能导致服务器崩溃。
      • 给服务器恢复时间: 如果是服务器端的问题导致连接断开,给它一些时间来恢复,而不是立即重连。
      • 避免死循环: 如果一直重连失败,没有延迟可能会导致客户端资源耗尽或界面卡死。

initWebSocket如何建立新连接?

  1. 接受连接参数:
    函数首先接收建立连接所需的所有信息,包括连接的唯一标识 name、服务器地址 wsuri、连接成功后要发送的 sendMsg、消息回调 callback 和错误回调 errBack

    function initWebSocket(name, wsuri, sendMsg, callback, errBack) {
      // ...
    }
    
  2. 存储连接配置(用于重连):
    在创建实际的 WebSocket 实例之前,它会将这些重要的连接配置信息存储在一个全局对象 wsObjs 中。这样做是为了在连接断开需要重连时,能够方便地获取到原始的连接参数,从而重新初始化连接。

    wsObjs[name] = {
      name: name,
      wsuri: wsuri,
      sendMsg: sendMsg,
      callback: callback,
      errBack: errBack,
      connectedLock: false, // 重连锁,初始化为 false
      timeoutnum: null,     // 重连定时器句柄
    };
    
  3. 创建新的 WebSocket 实例:
    这是建立连接最关键的一步。它使用 new WebSocket(wsuri) 构造函数来创建一个新的 WebSocket 对象。这个构造函数会立即尝试与指定的 wsuri 地址建立连接。

    websockObj[name] = new WebSocket(wsuri);
    
    • websockObj 是一个全局对象,用于存储所有活动的 WebSocket 实例。通过 name 作为键,可以管理多个独立的 WebSocket 连接。
    • 当 new WebSocket(wsuri) 被调用时,浏览器会立即开始进行底层的网络握手过程(HTTP/HTTPS 升级到 WS/WSS)。
  4. 设置各种事件监听器:
    一旦 WebSocket 实例被创建,initWebSocket 就会为其设置各种事件监听器,以便在连接状态发生变化时执行相应的逻辑。这些事件监听器的设置并不会立即建立连接,而是定义了连接建立、数据传输和断开时的行为

    • onopen (连接成功建立时触发):

      websockObj[name].addEventListener('open', () => {
        // 发送初始化消息(如果存在)
        sendMsg && websockObj[name].send(sendMsg);
        // 启动心跳机制
        start(name);
        // 调用外部成功回调(如果定义了)
        wsObjs[name].successBack && wsObjs[name].successBack();
      });
      

      这个事件表明 WebSocket 握手成功,连接已经建立并准备好发送和接收数据。在这里,它会发送初始消息并启动心跳。

    • onmessage (收到消息时触发):

      websockObj[name].onmessage = function(e) {
        // 心跳响应处理 (如果收到 'Pong' 则重置心跳定时器)
        if (e.data === 'Pong') {
          start(name);
        } else {
          // 调用外部传入的业务回调函数处理实际数据
          wsObjs[name].callback && wsObjs[name].callback(e);
        }
      };
      

      这个事件处理来自服务器的数据。

    • onerror (连接出错时触发):

      websockObj[name].onerror = function(err) {
        // 调用外部错误回调
        wsObjs[name].errBack && wsObjs[name].errBack(err);
        // 如果不是 token 错误,则尝试重新连接
        if (!tokenError) {
          reconnect(name);
        }
      };
      

      当连接过程中出现错误(例如网络问题、服务器拒绝连接等)时触发。

    • onclose (连接关闭时触发):

      websockObj[name].onclose = function(e) {
        // ... 判断是否正常关闭或 token 错误 ...
        if (!tokenError && !(e.code === 1000 && e.reason === 'leavePageClose')) {
          reconnect(name); // 如果是非正常关闭且非 token 错误,则尝试重新连接
        }
        // 调用外部错误回调
        wsObjs[name].errBack && wsObjs[name].errBack(e);
      };
      

      当连接被服务器关闭、浏览器关闭或因为其他原因断开时触发。

websocket 断线重连

  • 如何判断在线离线?

    当客户端第一次发送请求至服务端时会携带唯一标识、以及时间戳,服务端到db或者缓存去查询改请求的唯一标识,如果不存在就存入db或者缓存中,

    第二次客户端定时再次发送请求依旧携带唯一标识、以及时间戳,服务端到db或者缓存去查询改请求的唯一标识,如果存在就把上次的时间戳拿取出来,使用当前时间戳减去上次的时间,

    得出的毫秒秒数判断是否大于指定的时间,若小于的话就是在线,否则就是离线

  • 如何解决断线问题

    断线的可能原因1:websocket超时没有消息自动断开连接

    这时候我们就需要知道服务端设置的超时时长是多少,在小于超时时间内发送心跳包(客户端主动发送上行心跳包,或者服务端主动发送下行心跳包)

    下面主要讲一下客户端也就是前端如何实现心跳包:

    首先了解一下心跳包机制

    跳包之所以叫心跳包是因为:它像心跳一样每隔固定时间发一次,以此来告诉服务器,这个客户端还活着。事实上这是为了保持长连接,至于这个包的内容,是没有什么特别规定的,不过一般都是很小的包,或者只包含包头的一个空包。

    在TCP的机制里面,本身是存在有心跳包的机制的,也就是TCP的选项:SO_KEEPALIVE。系统默认是设置的2小时的心跳频率。但是它检查不到机器断电、网线拔出、防火墙这些断线。而且逻辑层处理断线可能也不是那么好处理。一般,如果只是用于保活还是可以的。

    下一个定时器,在一定时间间隔下发送一个空包给客户端,然后客户端反馈一个同样的空包回来,服务器如果在一定时间内收不到客户端发送过来的反馈包,那就只有认定说掉线了。

    在长连接下,有可能很长一段时间都没有数据往来。理论上说,这个连接是一直保持连接的,但是实际情况中,如果中间节点出现什么故障是难以知道的。更要命的是,有的节点(防火墙)会自动把一定时间之内没有数据交互的连接给断掉。在这个时候,就需要我们的心跳包了,用于维持长连接,保活。

    心跳检测步骤:

    1. 客户端每隔一个时间间隔发生一个探测包给服务器
    2. 客户端发包时启动一个超时定时器
    3. 服务器端接收到检测包,应该回应一个包
    4. 如果客户机收到服务器的应答包,则说明服务器正常,删除超时定时器
    5. 如果客户端的超时定时器超时,依然没有收到应答包,则说明服务器挂了
    // 前端解决方案:心跳检测
    var heartCheck = {
        timeout: 30000, //30秒发一次心跳
        timeoutObj: null,
        serverTimeoutObj: null,
        reset: function(){
            clearTimeout(this.timeoutObj);
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function(){
            var self = this;
            this.timeoutObj = setTimeout(function(){
                //这里发送一个心跳,后端收到后,返回一个心跳消息,
                //onmessage拿到返回的心跳就说明连接正常
                ws.send("ping");
                console.log("ping!")
    
                self.serverTimeoutObj = setTimeout(function(){//如果超过一定时间还没重置,说明后端主动断开了
                    ws.close(); //如果onclose会执行reconnect,我们执行ws.close()就行了.如果直接执行reconnect 会触发onclose导致重连两次
                }, self.timeout);
            }, this.timeout);
        }
    }
    

    断线的可能原因2:websocket异常包括服务端出现中断,交互切屏等等客户端异常中断等等

    **异常情况的可能原因:**服务器本身出现问题/在移动设备上切换应用、返回桌面,或者在浏览器中关闭标签页、最小化窗口等操作

    • 客户端的处理: “客户端需要主动断开连接,并通过 onclose 事件来关闭连接。” onclose 事件通常会在 WebSocket 连接被终止时触发。
    • 服务器的应对(重新上线时): “服务器再次上线时,则需要清除(之前因宕机而)存放的(与客户端相关的)数据。” 这用于维护数据的准确性和防止状态管理错误。
    • 不清除数据的后果: “如果不清除这些数据,就会造成任何(客户端)请求到服务器的都会被视为离线。” 这意味着,如果服务器没有重置它在连接中断期间存储的与客户端相关的状态信息,它可能会错误地认为客户端仍然处于离线状态或处于一个无效的状态,

    针对这种异常的中断解决方案就是处理重连,下面我们给出的重连方案是使用js库处理:引入reconnecting-websocket.min.js,ws建立链接方法使用js库api方法:

    var ws = new ReconnectingWebSocket(url);
    // 断线重连:reconnectSocket(){
        if ('ws' in window) {
            ws = new ReconnectingWebSocket(url);
        } else if ('MozWebSocket' in window) {
           ws = new MozWebSocket(url);
        } else {
          ws = new SockJS(url);
        }
    

    断网监测支持使用js库:offline.min.js

onLineCheck(){
    Offline.check();
    console.log(Offline.state,'---Offline.state');
    console.log(this.socketStatus,'---this.socketStatus');

    if(!this.socketStatus){
        console.log('网络连接已断开!');
        if(Offline.state === 'up' && websocket.reconnectAttempts > websocket.maxReconnectInterval){
            window.location.reload();
        }
        reconnectSocket();
    }else{
        console.log('网络连接成功!');
        websocket.send("heartBeat");
    }
}

// 使用:在websocket断开链接时调用网络中断监测websocket.onclose => () {
    onLineCheck();
};

引用

面试官:你知道websocket的心跳机制吗?大家好,我是泽南Zn👨‍🎓。在之前的一篇文章写到, 前端如何使用web - 掘金

面试官:说说对WebSocket的理解?应用场景? | web前端面试 - 面试官系列