灏天阁

websocket实现简单的聊天窗

· Yin灏

动画22222.gif

websocket 介绍

既然要基于 websocket 去实现简单的聊天窗,那首先就先介绍下 websocket 吧!

简介

webSocket 是一个网络通信协议,html5 才开始提供的一种在单个 TCP 连接上的全双工协议
http 是一种无状态,无连接,单向的应用层协议,采用了请求/响应模型,通信只能客户端发起,服务端应答。
http 协议无法实现服务器向客户端发信息,如果 http 需要实现长连接,只能通过轮询的方式,而且会导致一定的延迟,因为轮询需要定时,如 10s 发一次,20s 发一次等。

websocket 协议分为“握手”和“数据传输”(握手是基于 http 协议)
值得注意的是握手阶段,websocket 请求中会有一些比较有意思的东西:

  1. 发送请求时用的协议是 ws://localhost/chat,注意看这里的请求头是 ws 说明是 webSocket 协议
  2. connection:upgrade(标识该 http 请求是一个协议升级请求)
  3. upgrade:webSocket(把 http 升级为 ws)
  4. sec-webSocket-key:用于升级协议的标识,要求服务端响应一个对应加密的 sec-webSocket-accept 头信息应答

特点

  • 服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息
  • 与 HTTP 协议有着良好的兼容性。默认端口也是 80 和 443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。
  • 可以发送文本,也可以发送二进制数据。
  • 没有同源限制,客户端可以与任意服务器通信。
  • 协议标识符是 ws(如果加密,则为 wss),服务器网址就是 URL。

实现

上面简单的介绍了下 websocket 的背景,下面来实现一个基于 websocket 的聊天窗

页面模块

实现聊天窗的核心就是通过 websocket 去创建一个长连接,去向服务端发送数据,然后通过服务端分发到各个聊天窗的 websocket 实例。

首先在页面的 script 标签处先创建一个 webworket 实例:

// 注意这里的协议是ws开头的
ws = new WebSocket("ws://localhost:8000"); // 创建一个webSocket实例,这个是没有同源限制的

ws.onopen = function () {
  // 监听成功连接服务器的事件
  console.log(`连接成功`);
};

// 创建数据监听函数,如果服务端发送数据回来可以在这里监听到
ws.onmessage = function (message) {
  // 值得注意的时候传输的是以字符串格式或者blob格式传输,返回的数据可能是blob格式
  // 所以上传对象等类型数据是需要转JSON上传
  // 监听到返回的blob格式的数据时,再转成JSON格式,再将JSON格式转成对象或者其他的类型
  if (message.data instanceof Blob) {
    const change = new FileReader();
    change.readAsText(message.data, "UTF-8");
    change.onload = () => {
      // 服务端返回数据的列表
      messageList.push(JSON.parse(change.result));
    };
  }
};

// 监听连接错误事件
ws.onerror = function () {
  console.log("链接断开");
};

// 监听连接关闭事件
ws.onclose = function () {
  console.log("链接断开");
};

上面的代码中我们已经实现了一个基本 webworket 实例,我们可以通过 webworket 实例去监听这个长连接的数据接收,连接成功,连接关闭等事件,然后如果需要发送消息到服务端的话。

可以通过:

ws.send(data);

实现数据传输到服务端。

其实到这里就已经基本实现了聊天窗的页面部分模块,只需要在需要提交数据的地方调用 ws.send(data) 即可, 然后在ws.onmessage去做数据处理。

服务端模块

页面模块负责提交数据,那服务端模块负责干嘛呢?
没错就是 负责接收数据并分发。 下面展示下服务端代码:

// 引入ws模块
const WebSocket = require("ws");

const WebSocketServer = WebSocket.Server;

// 在8000端口上打开了一个WebSocket Server,该实例WebSocketServer引用
const WebSocketServer = new WebSocketServer({
  port: 8000,
});

// 如果有WebSocket请求接入,wss对象可以响应connection事件来处理这个WebSocket:
WebSocketServer.on("connection", function (ws) {
  console.log("连接成功"); // 进入回调说明连接正常

  // 回调里面的ws参数代码当前接入的WebSocket请求

  // 当前接入的WebSocke发送数据的事件
  ws.on("message", function (message) {
    // 遍历WebSocketServer的所有接入的WebSocket请求,统一通过send(message)发送一遍数据
    // 实现各个聊天窗都接收到消息。
    WebSocketServer.clients.forEach((item) => {
      item.send(message);
    });
  });

  // 当前接入的WebSocke关闭连接的事件
  ws.on("close", function () {
    console.log("连接关闭");
  });
});

心跳机制

为了保证 WebSocket 的稳定性,需要通过心跳机制保证当前的连接是否依旧正常。

Websocket 是前后端交互的长连接,前后端也都可能因为一些情况导致连接失效并且相互之间没有了反应。因此为了保证连接的可持续性和稳定性,Websocket 心跳机制就应运而生。

在使用原生 Websocket 的时候,如果设备网络断开,不会立刻触发 Websocket 的任何事件,前端也就无法得知当前连接是否已经断开。这个时候如果调用 Websocket.send 方法,浏览器才会发现链接断开了,便会立刻或者一定时间后(不同浏览器或者浏览器版本可能表现不同)触发 onclose 函数。

后端 Websocket 服务也可能出现异常,造成连接断开,这时前端也并没有收到断开通知,因此需要前端定时发送心跳消息 ping,后端收到 ping 类型的消息,立马返回消息,告知前端连接正常。如果一定时间没收到消息,就说明连接不正常,前端便会执行重连。

为了解决以上两个问题,以前端作为主动方,定时发送 ping 消息,用于检测网络和前后端连接问题。一旦发现异常,前端持续执行重连逻辑,直到重连成功。

所以我们在开发聊天窗的时候也需要考虑到这种情况,下面是心跳机制的简单实现:

核心是通过创建一个定时器,每隔一段时间就发个请求带上对应的标识。

如果请求失败会触发 error 或 close 的回调,然后在 error 或 close 的回调中使用重新连接函数,尝试重连接即可。

// 创建一个webSocket实例
ws = new WebSocket("ws://localhost:8000");
// 调用心跳检测函数给服务端定时传数据校验连接状态
checkHeart()
...

// 重新连接函数,在error或close的回调中使用,如果链接关闭就尝试重连
pingHeart() {
  clearInterval(this.ping);
  // closeWebSocket用于校验是否手动关闭,如果不是手动关闭就重连
  if (!closeWebSocket) {
    this.init();
  }
},

// 心跳检测
checkHeart() {
  console.log(this.ws);
  this.ping = setInterval(() => {
    console.log(this.ws);
    if (this.ws.readyState === 1) {
      this.ws.send(JSON.stringify({ type: "ping" }));
    }
  }, this.pingTime);
},

完整代码

通过上面的描述,我们大概知道怎么去实现一个聊天窗了,下面展示完整的代码及效果(基于 vue 和 node):

前端实现

<template>
  <div class="main">
    <div class="content">
      <div class="tips"></div>
      <div v-for="(item, index) in messageList" :key="index" class="info">
        <div :class="['user', item.user != 'xs' ? 'userOther' : 'userAdmin']">
          {{ item.user }}
        </div>
        <div class="text">{{ item.message }}</div>
      </div>
    </div>
    <div class="operation">
      <input class="send" @keyup.enter="sendMessage" v-model="message" />
      <button @click="sendMessage">发送</button>
      <button @click="change">更换角色</button>
      <button @click="close">手动关闭</button>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: "",
      messageList: [],
      ws: null,
      user: "xs",
      isHeart: true, // 是否开启心跳检查
      ping: null, // 暂存一个定时器去轮询心跳
      pingTime: 3000, // 轮询时长
      closeWebSocket: false, // 是否手动关闭
    };
  },
  mounted() {
    this.init();
  },
  methods: {
    init() {
      this.ws = new WebSocket("ws://localhost:8000");

      this.ws.onopen = function () {
        console.log(`连接成功`);
        if (this.isHeart) {
          // 心跳检测
          this.checkHeart();
        }
      }.bind(this);

      this.ws.onmessage = function (message) {
        if (message.data instanceof Blob) {
          const change = new FileReader();
          change.readAsText(message.data, "UTF-8");
          change.onload = () => {
            if (JSON.parse(change.result).type == "normal") {
              this.messageList.push(JSON.parse(change.result));
            }
            if (JSON.parse(change.result).type == "ping") {
              console.log("通信正常");
            }
          };
        }
      }.bind(this);

      this.ws.onerror = function () {
        console.log("链接断开");
        this.pingHeart();
      }.bind(this);

      this.ws.onclose = function () {
        console.log("链接断开");
        this.pingHeart();
      }.bind(this);
    },
    // 重新连接
    pingHeart() {
      clearInterval(this.ping);
      // 如果不是手动关闭就重连
      if (!this.closeWebSocket) {
        this.ws = null;
        this.init();
      }
    },
    // 心跳检测
    checkHeart() {
      console.log(this.ws);
      this.ping = setInterval(() => {
        console.log(this.ws);
        if (this.ws.readyState === 1) {
          this.ws.send(JSON.stringify({ type: "ping" }));
        }
      }, this.pingTime);
    },
    sendMessage() {
      if (this.message) {
        this.ws.send(
          JSON.stringify({
            type: "normal",
            user: this.user,
            message: this.message,
          })
        );
        this.message = "";
      } else {
        alert("请输入");
      }
    },
    change() {
      this.user = "jj";
    },
    close() {
      this.closeWebSocket = true;
      this.ws.close();
    },
  },
};
</script>
<style lang="less" scoped>
.main {
  position: relative;
  width: 100%;
  height: 100%;
  background-color: #f5f7fa;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  .content {
    background-image: url("https://img.zcool.cn/community/01c8f15aeac135a801207fa16836ae.jpg@1280w_1l_2o_100sh.jpg");
    background-size: 100% 100%;
    width: 800px;
    height: 800px;
    border-radius: 20px;
    padding: 20px;
    overflow-y: auto;
    .info {
      width: 100%;
      padding: 10px;
      display: flex;
      .user {
        width: 50px;
        height: 50px;
        color: #fff;
        border-radius: 50%;
        margin-right: 10px;
        line-height: 50px;
        text-align: center;
        color: #fff;
        font-size: 30px;
      }
      .userAdmin {
        background: blue;
      }
      .userOther {
        background: red;
      }
      .text {
        height: 50px;
        line-height: 50px;
        font-size: 30px;
      }
    }
  }
  .operation {
    width: 700px;
    justify-content: space-between;
    align-items: center;
    margin-top: 30px;
    display: flex;
    .send {
      background: #dcdcdd;
      border: none;
      width: 300px;
      height: 30px;
      padding: 10px;
      font-size: 20px;
      border-radius: 10px;
    }
    button {
      cursor: pointer;
      border: none;
      width: 100px;
      height: 50px;
      font-size: 16px;
      border-radius: 10px;
      background: #0051cbc4;
      line-height: 50px;
      text-align: center;
      color: #fff;
    }
  }
}
</style>

服务端实现

const WebSocket = require("ws");

const WebSocketServer = WebSocket.Server;

const wss = new WebSocketServer({
  port: 8000,
});
wss.on("connection", function (ws) {
  ws.on("message", function (message) {
    wss.clients.forEach((item) => {
      item.send(message);
    });
  });
  ws.on("close", function () {
    console.log("连接关闭");
  });
  ws.on("open", function () {
    console.log("连接成功");
  });
});

实际效果

动画22222.gif

- Book Lists -