前言

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

实现思路

WebSocket配置

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

1
2
3
4
5
6
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事件,并做相应的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
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推送的消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

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的核心逻辑基本可以归纳为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;然后

1
2
3
4
5
6
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,但不知道大家是否有更好的办法呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
useMemo(() => {
setShow(true);
if (!time) {
time = setTimeout(() => {
setShow(false);
}, 3000);
} else {
clearTimeout(time);
time = setTimeout(() => {
setShow(false);
}, 3000);
}
}, [message]);

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 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 " />