The Dawson Blog

We will all meet countless souls and weather countless stories, only to learn tenderness and release in the quiet flow of time.

Back to Blog

实现一个优雅的WebSocket弹窗

实现一个优雅的WebSocket弹窗
猜想设计

October 22, 2024


前言

最近再实习的过程中,碰到一个需要通过WebSocket连接服务器,并进行消息推送的需求,虽然leader说此项目当前不需要,实现弹窗效果,只需要直接展示出来即可,但我仍然想尝试实现一个优雅的WebSocket弹窗,于是下去研究了一下。

实现思路

WebSocket配置

首先,我们需要连接WebSocket服务器,并监听消息。

export function contectWebSocket() {
  const url = new URL("ws://localhost:4000/ws");
  url.searchParams.append("token", localStorage.getItem("token") || "");
  const socket = new WebSocket(url);
  return { socket, url };
}

上述我们已经简单的实现了WebSocket的连接,并获取到WebSocket对象,大家可以根据自己的需求进行修改。 不过相信大家也已经注意到了上述的token,这是为了实现WebSocket的鉴权,以及建立连接的唯一标识。主要用于服务端向特定用户推送消息

然后,我们监听WebSocket的openmessageerrorclose事件,并做相应的处理。

let lockReconnect = false;

export async function createWebSocket(
  setMessage: React.Dispatch<React.SetStateAction<IWSMessage>>
) {
  let timer: null | NodeJS.Timer = null;
  let { socket, url } = contectWebSocket();
  socket.onopen = () => {
    setInterval(() => {
      socket.send(
        JSON.stringify({
          type: "message",
          token: localStorage.getItem("token") || "",
        })
      );
    }, 2000);
  };

  socket.onmessage = function (event) {
    try {
      const message = JSON.parse(event.data);
      setMessage(message);
      console.log("Received message:", message);
    } catch (e) {
      console.log("Received non-JSON data:", e);
    }
  };

  // 监听关闭事件
  socket.onclose = () => {
    console.log("WebSocket connection closed");
    websocketReconnect();
  };

  // 监听错误事件
  socket.onerror = (error) => {
    console.error("WebSocket error observed:", error);
    websocketReconnect();
  };

  function websocketReconnect() {
    if (lockReconnect) {
      // 是否已经执行重连
      return;
    }
    console.log("尝试重连...");
    // 没连接上会一直重连,设置延迟避免请求过多
    if (!timer) {
      timer = setInterval(function () {
        console.log("1.尝试重连...");
        socket = new WebSocket(url);

        socket.onopen = function () {
          console.log("连接成功");
          lockReconnect = true;
          clearInterval(timer);
          timer = null;
          socket.send(JSON.stringify({ type: "subscribe", topic: "news" }));
        };

        socket.onclose = function () {
          console.log("连接关闭,准备重连");
          lockReconnect = false;
          websocketReconnect();
        };

        socket.onerror = function (error) {
          console.error("连接出错:", error);
          lockReconnect = false;
        };
      }, 3000);
    }
  }

  return socket;
}

为了避免连接中断或者别的特殊情况,我们需要实现一个自动重连的功能,这里我们使用了setInterval定时发送心跳包,如果连接中断,则自动重连。

弹窗组件

接下来,我们需要实现一个弹窗组件,用于展示WebSocket推送的消息。


import { IWSMessage } from "@/types/websocket";
import clsx from "clsx";
import { MessageSquareMoreIcon } from "lucide-react";
import { FC, useMemo, useState } from "react";

let time: NodeJS.Timeout | null = null;

function Content(message: IWSMessage | undefined) {
  // TODO: 根据消息类型返回不同的内容
  switch (message?.type) {
    case "text":
      return (
        <div className="flex items-end gap-2">
          <MessageSquareMoreIcon className="w-5 h-5" />
          现在的时间是 {message.content}
        </div>
      );
  }
}

const ScoketMessage: FC<{
  message: IWSMessage | undefined;
  className?: string;
}> = ({ message, className }) => {
  const [show, setShow] = useState(false);
  const date = new Date();
  const currentTime = `${date.getHours()}:${date.getMinutes()}`;
  useMemo(() => {
    setShow(true);
    if (!time) {
      time = setTimeout(() => {
        setShow(false);
      }, 3000);
    } else {
      clearTimeout(time);
      time = setTimeout(() => {
        setShow(false);
      }, 3000);
    }
  }, [message]);
  return (
    <div
      className={clsx(
        "flex items-center justify-around gap-2 w-auto min-w-[300px] bg-theme_gray rounded-md shadow-xl z-50 overflow-hidden opacity-60 backdrop-blur-sm font-medium ",
        className,
        {
          "transition-all duration-500  h-10 ease-in-out p-4": show,
          "transition-all duration-500  h-0 ease-in-out p-0": !show,
        }
      )}
    >
      {Content(message)}
      <p className="opacity-80">{currentTime}</p>
    </div>
  );
};
export default ScoketMessage;

整个ScoketMessage的核心逻辑基本可以归纳为

  useMemo(() => {
    setShow(true);
    if (!time) {
      time = setTimeout(() => {
        setShow(false);
      }, 3000);
    } else {
      clearTimeout(time);
      time = setTimeout(() => {
        setShow(false);
      }, 3000);
    }
  }, [message]);

message发生变化时,会触发缓存,更新time计时器状态,以此更新show state的状态。 通过show来控制弹窗显示和隐藏的样式渲染。

不知道大家是否注意到我是将ScoketMessage单独封装成组件,内部并无创建WebSocket连接,而是只是单纯的作为一个显示组件 其实,或许本该就封装成一个组件的,且因该创建WebSocket连接,但最开始也就出现问题在此处,如若我将WebSocket放在组件内部时 请注意message发生变化时会触发组件重渲染造成触发UseEffEct建立多次连接 或许大家看过React官方文档的会知道我们可以知道,在组件外声明let isInited = false;然后

  useEffect(() => {
    if (!isInited) {
      createWebSocket(setMessage);
      isInited = true;
    }
  }, []);

可以避免组件重渲染时多次建立WebSocket连接,但问题在于,我们仍希望尽量保持组件的纯粹,避免过多的副作用,因此,我们选择将WebSocket连接放在组件外部,并在组件内部使用useEffect监听message的变化,从而更新弹窗的显示和隐藏状态。(不过,因人而异吧,此处因为组件我是放在Layout布局中,其作为一个转接本生就作为一个高集成的组件,与其放于ScoketMessage不如直接放在Layout

思考

前段时间看了看React官网的Hooks介绍,其中讲到useMemo是一个昂贵的开销,在很多时候遇见一个state的更新会造成另一个state的更新,此时useMemo无疑是很方便的 但考虑到开销原因又很犹豫正如此处的,如若不使用useMemo则需要使用useEffect副作用,我之前考虑过useCallback但返回的是一个函数,意味着我并不能直接执行,且实际上和useMemo并无二置,因此,此处我被迫选择了useMemo,但不知道大家是否有更好的办法呢?

  useMemo(() => {
    setShow(true);
    if (!time) {
      time = setTimeout(() => {
        setShow(false);
      }, 3000);
    } else {
      clearTimeout(time);
      time = setTimeout(() => {
        setShow(false);
      }, 3000);
    }
  }, [message]);

考虑到Layout组件因人而异,如果大家需要使用的话只需加入三处代码在你代码的合适位置即可

  const [message, setMessage] = useState<IWSMessage>({
   type: "tetx",
   content: "欢迎来到 ISES",
 });
 // ——————————————————————————————————
   useEffect(() => {
   if (!isInited) {
     createWebSocket(setMessage);
     isInited = true;
   }
 }, []);
 // ——————————————————————————————————
 <ScoketMessage message={message} className="fixed sm:top-2 top-0 " />

Dawson
Dawson

Mar 22, 2025

PTA-Review-Plugin

前言 之前在去年11月份的时候写过一篇文章叫 PTA复习有答案干扰?不存在的! ,里面记录一些通过F12打开控制台手动操作dom,从而实现PTA题目的显示与隐藏,但一方面是操作起来比较麻烦,另一方面是PTA的页面结构经常变动,导致之前的代码失效,且如果每次都要去复制相应的代码,那也太麻烦了,所以当时我就想能不能写一个插

Continue reading

Sep 16, 2024

一种无状态验证码

前言 最近在用node.js写一个后端项目的时候,注册需要实现一个验证码功能,果断的向好友寻求帮助,然后bakptr将他的一种无状态验证码思路推荐给了我,后面再一段时间摸索后终于完成了相应的部署,并成功实现了验证码功能。今晚有空在这里记录一下,再次感谢bakptr的帮助。 实现思路 TOTP 首先,我们需要了解TOTP

Continue reading