React-Hook + WebSocket 实现一个超好用的页面提醒

8,729 阅读6分钟

image.png

「这是我参与2022首次更文挑战的第11天,活动详情查看:2022首次更文挑战


WebSocket 简介

WebSocket是HTML5新增的协议,它的目的是在浏览器和服务器之间建立一个不受限的双向通信的通道,比如说,服务器可以在任意时刻发送消息给浏览器。

为什么传统的HTTP协议不能做到WebSocket实现的功能?这是因为HTTP协议是一个请求-响应协议,请求必须先由浏览器发给服务器,服务器才能响应这个请求,再把数据发送给浏览器。换句话说,浏览器不主动请求,服务器是没法主动发数据给浏览器的。

这样一来,要在浏览器中搞一个实时聊天,或者在线多人游戏的话就没法实现了,只能借助Flash这些插件。

也有人说,HTTP协议其实也能实现啊,比如用轮询或者Comet。轮询是指浏览器通过JavaScript启动一个定时器,然后以固定的间隔给服务器发请求,询问服务器有没有新消息。这个机制的缺点一是实时性不够,二是频繁的请求会给服务器带来极大的压力。

Comet本质上也是轮询,但是在没有消息的情况下,服务器先拖一段时间,等到有消息了再回复。这个机制暂时地解决了实时性问题,但是它带来了新的问题:以多线程模式运行的服务器会让大部分线程大部分时间都处于挂起状态,极大地浪费服务器资源。另外,一个HTTP连接在长时间没有数据传输的情况下,链路上的任何一个网关都可能关闭这个连接,而网关是我们不可控的,这就要求Comet连接必须定期发一些ping数据表示连接“正常工作”。

以上两种机制都治标不治本,所以,HTML5推出了WebSocket标准,让浏览器和服务器之间可以建立无限制的全双工通信,任何一方都可以主动发消息给对方。

WebSocket协议

WebSocket并不是全新的协议,而是利用了HTTP协议来建立连接。我们来看看WebSocket连接是如何创建的。

首先,WebSocket连接必须由浏览器发起,因为请求协议是一个标准的HTTP请求,格式如下:

GET ws: //localhost:3000/ws/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Origin: http://localhost:3000
Sec-WebSocket-Key: client-random-string
Sec-WebSocket-Version: 13

该请求和普通的HTTP请求有几点不同:

  1. GET请求的地址不是类似 /path/,而是以 ws:// 开头的地址
  2. 请求头 Upgrade: websocketConnection: Upgrade 表示这个连接将要被转换为WebSocket连接
  3. Sec-WebSocket-Key 是用于标识这个连接,并非用于加密数据
  4. Sec-WebSocket-Version 指定了WebSocket的协议版本。

随后,服务器如果接受该请求,就会返回如下响应:

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket 
Connection: Upgrade 
Sec-WebSocket-Accept: server-random-string

该响应代码101表示本次连接的HTTP协议即将被更改,更改后的协议就是Upgrade: websocket指定的WebSocket协议。

版本号和子协议规定了双方能理解的数据格式,以及是否支持压缩等等。如果仅使用WebSocket的API,就不需要关心这些。

现在,一个WebSocket连接就建立成功,浏览器和服务器就可以随时主动发送消息给对方。消息有两种,一种是文本,一种是二进制数据。通常,我们可以发送JSON格式的文本,这样,在浏览器处理起来就十分容易。

为什么WebSocket连接可以实现全双工通信而HTTP连接不行呢?实际上HTTP协议是建立在TCP协议之上的,TCP协议本身就实现了全双工通信,但是HTTP协议的请求-应答机制限制了全双工通信。WebSocket连接建立以后,其实只是简单规定了一下:接下来,咱们通信就不使用HTTP协议了,直接互相发数据吧。

安全的WebSocket连接机制和HTTPS类似。首先,浏览器用wss://xxx创建WebSocket连接时,会先通过HTTPS创建安全的连接,然后,该HTTPS连接升级为WebSocket连接,底层通信走的仍然是安全的SSL/TLS协议。

image.png

很显然,要支持WebSocket通信,浏览器得支持这个协议,这样才能发出ws://xxx的请求。目前,支持WebSocket的主流浏览器如下:

  • Chrome
  • Firefox
  • IE >= 10
  • Sarafi >= 6
  • Android >= 4.4
  • iOS >= 8

image.png

服务器

由于WebSocket是一个协议,服务器具体怎么实现,取决于所用编程语言和框架本身。Node.js本身支持的协议包括TCP协议和HTTP协议,要支持WebSocket协议,需要对Node.js提供的HTTPServer做额外的开发。已经有若干基于Node.js的稳定可靠的WebSocket实现,我们直接用npm安装使用即可。

为什么会进行心跳检测

简单地说是为了证明客户端和服务器还活着。websocket 在使用过程中,如果遭遇网络问题等,这个时候服务端没有触发 onclose 事件,这样会产生多余的连接,并且服务端会继续发送消息给客户端,造成数据丢失。因此需要一种机制来检测客户端和服务端是否处于正常连接的状态,心跳检测和重连机制就产生了。


理论存在,实践开始

技术栈:React + Antd + Typescript

const Notification = () => {
  return null;
}
export { Notification }

初始化

刚开始可以判断用户是否处于登陆状态,当然这一步可以省略。 使用 WebSocket 构造函数进行初始化,监听 open, message, error, close 事件

const [reset, setReset] = useState<boolean>(false);
const socket = useRef<WebSocket>();

const socketInit = useCallback(() => {
  try {
    if (!userInfo) return;
    const url = `${webSocketUrl}/*******/****`;
    const socketObj = new WebSocket(url, userInfo);
    socketObj.addEventListener("close", socketOnClose);
    socketObj.addEventListener("error", socketOnError);
    socketObj.addEventListener("message", socketOnMessage);
    socketObj.addEventListener("open", socketOnOpen);
    socket.current = socketObj;
  } catch (e) {
    console.log(e);
  }
}, [socketOnMessage, socketOnOpen, userInfo]);

useEffect(() => {
  socketInit();
}, [socketInit]);

useEffect(() => {
  if (!reset) return;
  setTimeout(() => {
    socketInit();
    setReset(false);
  }, 30000);
}, [reset, socketInit]);

一个项目,正常来说,会有很多个环境,本地,测试,预发,线上等等。但是如果每次换一个环境,我们就去修改一次代码,那也太麻烦了。更何况,有时还容易忘记,写着写着,提交了,一部署上线,就凉凉了。

image.png

使用 process.env 环境变量,进行切换。

enum BuildEnv {
  local = "local",
  dev = "dev",
  pre = "pre",
}

export const BUILD_ENV = process.env.BUILD_ENV as BuildEnv;

const webSocketUrl = {
  [BuildEnv.local]: "ws://*******",
  [BuildEnv.dev]: "ws://*******",
  [BuildEnv.pre]: "wss://******",
}[BUILD_ENV];

WebSocket为了保持客户端、服务端的实时双向通信,需要确保客户端、服务端之间的TCP通道保持连接没有断开。然而,对于长时间没有数据往来的连接,如果依旧长时间保持着,可能会浪费包括的连接资源。

但不排除有些场景,客户端、服务端虽然长时间没有数据往来,但仍需要保持连接。这个时候,可以采用心跳来实现。

  • 发送方->接收方:ping
  • 接收方->发送方:pong

ping、pong的操作,对应的是WebSocket的两个控制帧,opcode分别是0x9、0xA。

image.png

定义变量

let timerPing = 0;
let timerPong = 0;

const PADDING_TIME = 5000;
const CLOSE_TIME = PADDING_TIME * 3;

监听到 open 事件,每隔一段时间,就 send(ping)。如果刚开始就存 timerPing,先清空。

socketObj.addEventListener("open", socketOnOpen);
const socketOnOpen = useCallback(() => {
  // 先触发一次
  if (socket?.current?.readyState === WebSocketStatus.OPEN) {
    socket?.current?.send(SocketMessage.ping);
  }
  if (timerPing) window.clearInterval(timerPing);
  timerPing = window.setInterval(() => {
    if (socket?.current?.readyState === WebSocketStatus.OPEN) {
      socket?.current?.send(SocketMessage.ping);
    }
  }, PADDING_TIME);
  pongHeart();
}, [pongHeart]);

const pongHeart = useCallback(() => {
  if (timerPong) window.clearTimeout(timerPong);
  timerPong = window.setTimeout(() => {
    socket?.current?.close();
  }, CLOSE_TIME);
}, []);

监听 message 事件,如果后端返回的是 message pong,那就调用 pongHeart(),否则就是正常消息,进行后续的处理。

const socketOnMessage = useCallback(
  (e: MessageEvent) => {
    // 心跳链接
    if (e.data === SocketMessage.pong) {
      pongHeart();
      return;
    }
    const data: Message = JSON.parse(e.data);
    // .....
  },
  [pongHeart],
);

监听 close 事件,置空 timerPing,timerPong

const socketOnClose = () => {
  console.log("链接关闭");
  if (timerPing) {
    window.clearInterval(timerPing);
  }
  if (timerPong) {
    window.clearTimeout(timerPong);
  }
  setReset(true);
};

监听 error 事件

const socketOnError = (e: Event) => {
  console.error(e);
};

UI 的样式,直接采用 Antd 中的 Notification 通知提醒框。

import { Button, notification } from 'antd';

const openNotification = () => {
  notification.open({
    message: 'Notification Title',
    description:
      'This is the content of the notification. ',
    onClick: () => {
      console.log('Notification Clicked!');
    },
  });
};

ReactDOM.render(
  <Button type="primary" onClick={openNotification}>
    Open the notification box
  </Button>,
  mountNode,
);

image.png

什么时候出现这个框?当前端接收到来自后端的消息,并且该消息不为 pong 的时候。展示该提醒框,并将 data 填入其中。

const socketOnMessage = useCallback(
  (e: MessageEvent) => {
    // 心跳链接
    if (e.data === SocketMessage.pong) {
      pongHeart();
      return;
    }
    openNotification(data);
  },
  [handleIgnore, openNotification, pongHeart],
);
const openNotification = useCallback(
  (data: Message) => {
    notification.open({
      message: "",
      description: data,
      duration: null,
      key: `${data.msgId}`,
    });
  }, []);

到目前为止,基本上的一个页面提醒功能,就实现了。当后端主动推送消息的时,前端接收到对应的信息,并展示出来。

不过,对于一个提示框来说,可能还有其他选项,比如,10分钟后提醒⏰,忽略该消息,查看详情之类的。

SubscribeRemind.tsx

const SubscribeRemind = (props: SubscribeRemindProps) => {

};

export default SubscribeRemind;

notification 的 description 不仅可以展示一个文本,还可以渲染一个 React.Node。

const openNotification = useCallback(
  (data: Message) => {
    notification.open({
      message: "",
      description: (
        <SubscribeRemind
          openDetail={openDetail}
          handleRemind={handleRemind}
          handleIgnore={handleIgnore}
          content={data}
        />
      ),
      duration: null,
      key: `${data.msgId}`,
    });
  },
  [handleIgnore, handleRemind, openDetail],
);
const SubscribeRemind = (props: SubscribeRemindProps) => {
  const { content, openDetail, handleRemind, handleIgnore } = props;

  return (
    <div className="subscribe-remind">
      <span
        className="ignore_text"
        style={{ color: "#3b73dd" }}
        onClick={ignoreClick}
      >
        忽略
      </span>
      <p className="subscribe_text">
        {content}
      </p>
      <p className="bottom-content">
        <span className="bottom-text" onClick={remindTenClick)}>
          10分钟后提醒
        </span>
        <span className="bottom-text" onClick={openDetailClick}>
          查看详情
        </span>
      </p>
    </div>
  );
};

export default SubscribeRemind;

提示框的忽略功能

const ignoreClick = useCallback(() => {
  if (!content) return;
  handleIgnore(content.msgId);
}, [handleIgnore, content]);

父组件中调用 notification 的 close 方法,传入 id,即可。

const handleIgnore = useCallback((id: number) => {
  notification.close(id);
}, []);

10 分钟后提醒,点击了 10分钟后提醒的同时,也需要关闭这个提示框。

const remindTenClick = useCallback(() => {
  if (!content) return;
  handleRemind({
    msgId: content.msgId,
  });
  ignoreClick();
}, [handleRemind, ignoreClick, content]);

父组件

const handleRemind = useCallback((params: RemindTenClickParams) => {
  const requestBody = {
    id: params.reserveId,
    time: 10,
  };
  post("/localhost:5789/v1/***", requestBody);
}, []);

查看详情

const openDetailClick = useCallback(() => {
  if (!content) return;
  openDetail({
    id: content.id,
  });
  ignoreClick();
}, [ignoreClick, content, openDetail]);

父组件,拿到 id 之后,之后剩余的操作,就是业务的具体实现了。

const openDetail = useCallback((params) => {
  //.....
}, []);

image.png

参考文章:

WebSocket 心跳检测和重连机制