WebSocket的应用:前后端详解与使用

730 阅读9分钟

一、简介

WebSocket是一种网络通信协议,它提供了在单个TCP连接上进行全双工通信的功能。在下面这个聊天应用示例中,WebSocket被用于实现实时的聊天功能,包括用户之间的消息发送、接收,用户状态管理以及其他相关的交互操作,为用户带来流畅的聊天体验。

二、后端实现

(一)模块引入与初始化

后端代码基于Node.js实现。首先,引入必要的模块:

const { WebSocketServer } = require("ws");
const WebSocket = require("ws");
const { getOneUserInfo } = require("../service/user");
const { createChat } = require("../service/chat/index");
// 在线列表
const onlineList = [];

initWebsocket函数是整个WebSocket后端功能的核心初始化函数:

const initWebsocket = () => {
  // 设置WebSocket服务的端口号
  const wss = new WebSocketServer({ port: 8889 });

  if (wss) {
    console.log("websocket Initialized successfully on port: " + 8889);
  }
};

在这个函数中,创建了一个监听在8889端口的WebSocketServer实例(wss),并在创建成功后在控制台打印相应信息。

(二)连接事件处理

当有新的连接建立(wss.on("connection",... ))时: 1.错误处理 为每个新连接设置错误处理(ws.on("error", console.error);),这样当连接出现错误时,错误信息会在控制台输出,方便调试。 2. 消息处理 当新连接收到消息(ws.on("message",... ))时,先将接收到的消息数据解析为JSON对象,然后根据消息的type属性进行不同的处理: - 初始化消息(init类型)

case "init":
  if (message.user_id) {
    // 为当前用户的 ws连接绑定 用户id 用于用户断开链接时 改变用户在线状态
    ws.user_id = message.user_id;
    const user = await getOneUserInfo({ id: message.user_id });
    if (user) {
      message.nick_name = user.nick_name;
      message.avatar = user.avatar;
      // 上线
      keepLatestOnlineList("online", message);
    }
  } else {
    sendOnlineToAll();
  }
  break;

当用户连接成功后发送初始化消息时,如果消息中包含user_id,则将该用户ID绑定到当前的WebSocket连接对象上。接着通过getOneUserInfo获取用户信息,如果获取成功,将用户的昵称和头像信息添加到消息对象中,并调用keepLatestOnlineList函数将用户标记为在线状态,同时向所有在线用户发送在线用户列表。若消息中没有user_id,则直接调用sendOnlineToAll函数。 - 普通消息(message类型)

case "message":
  const user = await getOneUserInfo({ id: message.user_id });
  if (user) {
    message.nick_name = user.nick_name;
    message.avatar = user.avatar;
  }
  const res = await createChat(message);
  if (res) {
    message.id = res.id;
  }
  wss.clients.forEach(function each(client) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(message), { binary: false });
    }
  });
  break;

对于用户发送的普通消息,先使用filterSensitive函数过滤消息内容中的敏感信息。然后通过getOneUserInfo获取用户信息并添加到消息对象中。接着调用createChat创建聊天记录,如果创建成功,将聊天记录的ID添加到消息对象中。最后,遍历所有连接的客户端,如果客户端处于打开状态,则将消息发送给该客户端。 - 撤回消息(revert类型)

case "revert":
  if (message.message_id) {
    wss.clients.forEach(function each(client) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message), { binary: false });
      }
    });
  }
  break;

当收到撤回消息的请求且消息中包含message_id时,遍历所有连接的客户端,将撤回消息的ID发送给所有在线客户端。 - 用户下线(offline类型)

case "offline":
  if (message.user_id) {
    // 下线用户
    getOneUserInfo({ id: message.user_id }).then((user) => {
      if (user) {
        keepLatestOnlineList("close", { user_id: user.id, nick_name: user.nick_name });
      }
    });
  }
  break;

当收到用户下线消息且消息中包含user_id时,通过getOneUserInfo获取用户信息,然后调用keepLatestOnlineList函数将用户标记为离线状态。

  1. 连接关闭处理 当WebSocket连接被动断开(ws.on("close",... ))时:
ws.on("close", function () {
  if (ws.user_id) {
    getOneUserInfo({ id: ws.user_id }).then((user) => {
      if (user) {
        keepLatestOnlineList("close", { user_id: ws.user_id, nick_name: user.nick_name });
      }
    });
  }
});

如果连接的ws对象有user_id,则获取用户信息,并调用keepLatestOnlineList函数将用户标记为离线状态。

(三)在线用户列表维护

keepLatestOnlineList函数用于维护在线用户列表:

function keepLatestOnlineList(type, message) {
  let index = onlineList.findIndex((item) => item.user_id === message.user_id);
  switch (type) {
    case "online":
      if (index!== -1) {
        onlineList.splice(index, 1);
      }
      onlineList.push({
        user_id: message.user_id,
        nick_name: message.nick_name,
        avatar: message.avatar,
        createTime: new Date(),
      });
      console.log(message.nick_name + " 上线了...");
      break;
    case "close":
      if (index!== -1) {
        onlineList.splice(index, 1);
        if (message.nick_name) {
          console.log(message.nick_name + " 断开连接...");
        }
      }
      break;
    default:
      break;
  }
  sendOnlineToAll();
}

它首先通过findIndexonlineList中查找用户的索引。当用户上线(type"online")时,如果用户已在列表中则先删除旧记录,然后将新的用户信息添加到列表中,并在控制台打印上线提示信息。当用户离线(type"close")时,如果用户在列表中则将其删除,并在有昵称的情况下打印离线提示信息。最后,无论哪种情况,都会调用sendOnlineToAll函数向所有在线用户发送最新的在线用户列表。

sendOnlineToAll函数用于向所有在线用户群发在线人数信息:

function sendOnlineToAll() {
  // 群发在线人数
  let latestList = [];
  wss.clients.forEach(function each(client) {
    if (client.readyState === WebSocket.OPEN) {
      latestList.push(client.user_id);
      let message = JSON.stringify({
        type: "onlineList",
        list: onlineList,
      });
      client.send(message, { binary: false });
    }
  });
}

它遍历所有连接的客户端,将处于打开状态的客户端的user_id添加到临时列表中,然后创建一个包含在线用户列表的消息对象,将其序列化为JSON字符串后发送给所有在线客户端。

三、前端实现

(一)功能函数实现

1.发送消息函数(sendMessagewsSend sendMessage函数是发送消息的入口:

const sendMessage = async () => {
  messageType.value = "text";
  if (!getUserInfo.value.id) {
    ElNotification({
      offset: 60,
      title: "温馨提示",
      duration: 3000,
      message: h("div", { style: "color: #e6c081; font-weight: 600;" }, "请先登录"),
    });
    return;
  }
  if (!inputChatRef.value.innerHTML) {
    ElNotification({
      offset: 60,
      duration: 3000,
      title: "温馨提示",
      message: h("div", { style: "color: #e6c081; font-weight: 600;" }, "请输入消息再发送"),
    });
    return;
  }

  if (websocket.readyState!== 1) {
    // 重连后再发送
    reConnect();
  } else {
    // 在线就 直接发送
    wsSend();
  }
};

它首先将messageType设置为"text",然后检查用户是否登录和输入框是否有内容。如果WebSocket连接未处于打开状态,则调用reConnect函数重连后再发送;如果连接打开,则调用wsSend函数。wsSend函数根据messageType的值构建不同类型的消息(文本或图片)并发送:

const wsSend = () => {
  let message;
  switch (messageType.value) {
    case "text":
      if (!inputChatRef.value.innerHTML) return;
      message = {
        type: "message",
        user_id: getUserInfo.value.id,
        content: inputChatRef.value.innerHTML,
        content_type: "text", // 信息是文本
      };
      websocket.send(JSON.stringify(message));
      inputChatRef.value.innerHTML = "";
      break;
    case "image":
      if (!yourImageUrl.value) return;
      message = {
        type: "message",
        user_id: getUserInfo.value.id,
        content: yourImageUrl.value,
        content_type: "image", // 信息是文本
      };
      websocket.send(JSON.stringify(message));
      yourImageUrl.value = "";
      imageUpload.value && imageUpload.value.clearFiles();
      break;
    default:
      break;
  }
};

发送完成后进行相应的清理操作,如清空输入框或清除图片上传组件中的文件。

2.WebSocket初始化与重连(initWebsocketreConnect initWebsocket函数用于初始化WebSocket连接,也可用于重连(通过参数isReconnect判断):

const initWebsocket = async (isReconnect = false) => {
  isConnecting.value = true;

  // 如果说发现了异常 断开连接了 之前的websocket 还在的话就清空 重连
  if (websocket) {
    websocket.close();
    websocket = null;
  }

  // websocket = new WebSocket("ws://mrzym.top/ws/");
  websocket = new WebSocket("ws://localhost:8889/");

  if (websocket) {
    websocket.onopen = () => {
      isConnecting.value = false;
      websocket.send(
        JSON.stringify({
          type: "init",
          user_id: getUserInfo.value.id || "",
        })
      );
      console.log("WebSocket连接成功");

      // 连上以后设置心跳检测 如果断开就重新连接 并清空之前的心跳检测 防止内存泄漏
      clearInterval(heartBreak);
      heartBreak = null;

      heartBreak = setInterval(() => {
        if (websocket.readyState!== 1) {
          reConnect();
        }
      }, 30000);
    };
    websocket.onmessage = (event) => {
      if (event.data) {
        const data = JSON.parse(event.data);
        let index;
        // tips 表示提示 message 表示用户发送的消息
        switch (data.type) {
          case "tips":
            if (isReconnect) {
              // 这里重连就重新发送
              wsSend();
              console.log("重连成功");
            } else {
              ElNotification({
                offset: 60,
                title: "提示",
                duration: 3000,
                message: h("div", { style: "color: #7ec050; font-weight: 600;" }, data.content),
              });
            }
            break;
          case "message":
            if (data.content) {
              messageList.value.push(data);
              if (data.user_id!== getUserInfo.value.id) {
                newMessageCount.value++;
              }
              nextTick(() => {
                scrollToBottom();
              });
            }
            break;
          case "onlineList":
            onlineList.value = data.list;
            index = onlineList.value.findIndex((item) => item.user_id === getUserInfo.value.id);
            if (index === -1) {
              clearWebsocket();
            }
            break;
          case "revert":
            index = messageList.value.findIndex((item) => item.id === data.message_id);
            if (index!== -1) {
              messageList.value.splice(index, 1);
            }
            break;
          default:
            break;
        }
      }
    };
    websocket.onerror = () => {
      console.log("WebSocket连接错误");
    };
  } else {
    console.log("WebSocket连接失败");
    ElNotification({
      offset: 60,
      title: "错误提示",
      duration: 3000,
      message: h(
        "div",
        { style: "color: #f56c6c; font-weight: 600;" },
        "聊天室连接失败 正在重新连接"
      ),
    });
    if (timer) return;
    timer = setInterval(() => {
      reConnectionCount.value++;
      initWebsocket();

      // 连上了就不重连了
      if (websocket) {
        clearInterval(timer);
      }
      // 尝试五次 实在是连不上就不连了
      if (reConnectionCount.value == 5) {
        clearInterval(timer);
      }
    }, 5000);
  }
};

在初始化过程中,先设置isConnectingtrue,如果之前存在websocket则关闭它。然后创建一个新的WebSocket连接,连接成功后发送初始化消息,设置连接成功后发送初始化消息,设置心跳检测定时器。当接收到服务器消息时,根据消息类型进行处理,如处理提示信息、新消息、在线用户列表更新、消息撤回等。如果连接出现错误或连接失败,会进行相应的提示和重连操作。reConnect函数只是简单地调用initWebsocket并传入true

const reConnect = () => {
  initWebsocket(true);
};

3.其他功能函数 - getMessageList函数用于获取聊天消息列表:

const getMessageList = async () => {
  loadingMessage.value = true;
  const res = await getChatList({
    size: 10,
    last_id: messageList.value.length > 0? messageList.value[0].id : "",
  });

  if (res.code == 0) {
    const list = res.result.list;

    if (messageList.value.length > 0) {
      if (Array.isArray(list) && list.length) {
        messageList.value = list.concat(messageList.value);
        if (list.length == 10) {
          canLoadMore.value = true;
        } else {
          canLoadMore.value = false;
        }
      } else {
        canLoadMore.value = false;
      }
    } else {
      if (Array.isArray(list) && list.length) {
        messageList.value = list;
        if (list.length == 10) {
          canLoadMore.value = true;
        } else {
          canLoadMore.value = false;
        }
      } else {
        canLoadMore.value = false;
      }
    }
    loadingMessage.value = false;
  }
};

根据获取结果更新messageListcanLoadMore等响应式数据。 - clearHistory函数用于清空聊天记录:

const clearHistory = async () => {
  ElMessageBox.confirm("确认清空吗", "提示", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
  }).then(async () => {
    const res = await clearChat();
    if (res.code == 0) {
      ElNotification({
        offset: 60,
        title: "提示",
        duration: 3000,
        message: h("div", { style: "color: #7ec050; font-weight: 600;" }, "聊天记录已清空"),
      });
      messageList.value = [];
      canLoadMore.value = false;
    }
  });
};

通过弹框确认后调用clearChat API函数,并在成功后更新相关数据和显示提示信息。 - offlineUser函数用于强制某个用户下线:

const offlineUser = (user_id, nick_name) => {
  ElMessageBox.confirm(`确认强制下线${nick_name}吗`, "提示", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
  }).then(() => {
    websocket &&
      websocket.send(
        JSON.stringify({
          type: "offline",
          user_id: user_id,
        })
      );
  });
};

通过弹框确认后向服务器发送下线指令。 - scrollToBottom函数用于将聊天容器滚动到最底部:

const scrollToBottom = () => {
  chatContainerRef.value &&
    chatContainerRef.value.scrollTo({
      top: chatContainerRef.value.scrollHeight,
      behavior: "smooth",
    });
};

以显示最新的聊天消息。 - selectIcon函数用于将用户选择的图标(如表情)插入到输入框中,并更新光标的位置索引:

const selectIcon = (val) => {
  const text = val;
  if (currentIndex.value == inputChatRef.value.innerHTML.length) {
    inputChatRef.value.innerHTML += `${text}`;
  } else {
    // 拼接表情
    let input = inputChatRef.value.innerHTML;
    let start = input.slice(0, currentIndex.value);
    let end = input.slice(currentIndex.value);
    inputChatRef.value.innerHTML = start + `${text}` + end;
  }
  // 每次拼接完就加一下下标 一个表情的长度是两个字节
  currentIndex.value += 2;
};
const keepIndex = () => {
  currentIndex.value = getCurrentIndex();
};

function getCurrentIndex() {
  var range;
  if (window.getSelection) {
    //ie11 10 9 ff safari
    range = window.getSelection();
    return range.focusOffset;
  } else if (document.selection) {
    range = document.selection.createRange();
    return range.focusOffset;
  }
}
- `handleChange`函数用于处理图片上传操作:
const handleChange = async (uploadFile) => {
  imageUploading.value = true;
  const img = await imgUpload(uploadFile);
  if (img.code == 0) {
    const { url } = img.result;
    yourImageUrl.value = url;
    messageType.value = "image";
    wsSend();
    imageUploading.value = false;
  }
};

上传成功后更新相关数据并发送图片消息。 - revertOneChat函数用于撤回一条聊天消息:

const revertOneChat = async (id) => {
  if (!id) return;
  const res = await deleteOneChat(id);
  if (res.code == 0) {
    let index = messageList.value.findIndex((item) => item.id === id);
    if (index!== -1) {
      messageList.value.splice(index, 1);
    }
    // websocket 发送撤回消息的信息 通知其他用户撤回消息
    websocket.send(
      JSON.stringify({
        type: "revert",
        message_id: id,
      })
    );
    ElNotification({
      offset: 60,
      title: "提示",
      duration: 3000,
      message: h("div", { style: "color: #7ec050; font-weight: 600;" }, "撤回成功"),
    });
  }
};

先删除本地消息列表中的消息,然后向服务器发送撤回消息的通知。 - clearWebsocket函数用于关闭WebSocket连接并清理相关的定时器资源:

const clearWebsocket = () => {
  websocket && websocket.close();
  websocket = null;
  clearInterval(heartBreak);
  heartBreak = null;
};

(二)数据监听与生命周期钩子

使用watch监听getUserInfo.value.id的变化,当用户ID改变时,重新初始化WebSocket连接并设置hasLoadedtrue

watch(
  () => getUserInfo.value.id,
  async () => {
    await initWebsocket();
    hasLoaded.value = true;
  },
  {
    immediate: true,
  }
);

同时监听chatVisible.value的变化,根据聊天窗口的可见性设置文档的overflowY样式:

watch(
  () => chatVisible.value,
  (newV) => {
    if (newV) {
      document.documentElement.style.overflowY = "hidden";
    } else {
      document.documentElement.style.overflowY = "visible";
    }
  },
  {
    immediate: true,
  }
);

onMounted生命周期钩子中调用getMessageList函数获取聊天消息列表:

onMounted(() => {
  getMessageList();
});

onBeforeUnmount钩子中调用clearWebsocket函数关闭WebSocket连接:

onBeforeUnmount(() => {
  clearWebsocket();
});

如果对你有帮助,记得三连哦!!!