Вступление

В современной веб-разработке существует множество способов обновления данных в реальном времени. Среди наиболее распространённых методов выделяются Polling и WebSockets. Оба подхода имеют свои уникальные особенности и применения, что делает их подходящими для различных сценариев. В этой статье мы рассмотрим основные различия между Polling и WebSockets, их преимущества и недостатки, а также приведем примеры использования каждого из них в React приложениях на хуках.

Polling

Polling – это метод, при котором клиент периодически отправляет запросы на сервер для получения обновленных данных. Этот подход прост в реализации, но может быть неэффективен при частых запросах, так как он увеличивает нагрузку на сервер и сеть.

Untitled
Untitled

Рефетч может происходить при нажатии кнопки, по истечению определенного количества времени, или при любом другом событии.

WebSockets

WebSockets – это протокол, который позволяет устанавливать постоянное соединение между клиентом и сервером. Благодаря этому соединению данные могут передаваться в обоих направлениях в реальном времени, что делает WebSockets более эффективным для приложений, требующих мгновенного обновления данных.

Untitled
Untitled

Клиент отправляет запрос, а сервер возвращает ему handshake, после чего устанавливается websocket соединение, в котором данные моментально приходят от клиента до сервера, и наоборот.

Рассмотрим примеры реализации обоих подходов

Создадим 2 приложения: в первом реализуем чат на основе поллинга, добавим пользователю кнопку “обновить”, на которую будет происходить рефетч; во втором же сделаем чат и установим web socket соединение, для моментального обновления данных.

Зависимости

Для реализации задумки мне пригодится всего 2 библиотеки:

  • shadcn/ui - для красивых ui компонентов

  • reactuse - лучшая утилитарная библиотека с огромным количеством переиспользуемых react хуков (мы возьмем оттуда useQuery и useWebsocket)

Установим зависимости и нужные нам компоненты из ui библиотеки (это начало будет одинаковым для обоих приложений)

$yarn add @siberiacancode/reactuse
$yarn add tailwindcss
$npx tailwindcss init
$npx shadcn-ui@latest init
$npx shadcn-ui@latest add button card input

Для обоих приложений создадим одинаковый компонент чата. Начнем с обычной верстки. Для этого импортируем из shadcn/ui следующие компоненты: button, input, scroll-area, separator. (см. документацию, чтобы посмотреть как добавлять компоненты)

Создадим развертку компонента Chat:

import { Button } from "./ui/button";
import { Card, CardContent, CardTitle } from "./ui/card";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { Separator } from "./ui/separator";
const Chat = () => {
return (
<Card className=" w-80 h-96">
<CardTitle>
<div className="p-2">Chat</div>
<Separator />
</CardTitle>
<CardContent className="p-0 flex flex-col">
<ScrollArea className=" w-full h-72">
<div className=" p-2">
<div className="flex">
<div className=" bg-slate-300 text-background rounded-md p-1.5 max-w-64 shadow-slate-300 shadow-md">
Message to you
</div>
</div>
<div className="flex justify-end">
<div className=" bg-slate-800 text-foreground rounded-md p-1.5 max-w-64 shadow-slate-800 shadow-md">
Message from you
</div>
</div>
</div>
</ScrollArea>
<div className=" flex ">
<Input placeholder="Your message..." className="" />
<Button className="">Send</Button>
</div>
</CardContent>
</Card>
);
};
export default Chat;

Получим следующий ui:

Untitled
Untitled

Добавим type Message:

type Message = {
  text: string;
  type: "client" | "server";
};

Создадим состояние messages, в котором будем хранить все сообщения:

import { useState } from "react";
const messages = useState<Message[]>([])

Немного поменяем верстку, чтобы в чате отоброжались только сообщения из стэйта messages и на них применялись определенные стили, в соответствии с тем, является ли тип сообщения “server” или “client”

<ScrollArea className=" w-full h-72">
  <div className="p-2">
    {messages.map((message, index) => {
      if (message.type === "client")
        return (
          <div className="flex justify-end" key={index}>
            <div className=" bg-slate-800 text-foreground rounded-md p-1.5 max-w-64 shadow-slate-800 shadow-md">
              {message.text}
            </div>
          </div>
        );
      else
        return (
          <div className="flex">
            <div className=" bg-slate-300 text-background rounded-md p-1.5 max-w-64 shadow-slate-300 shadow-md">
              {message.text}
            </div>
          </div>
        );
    })}
  </div>
</ScrollArea>;

Добавим хук работы с полями ввода

useField - этот хук из пакета reactuse вы можете использовать для удобного взаимодествия с полями ввода.

const messageInput = useField({initialValue: ''})

Здесь мы задали initialValue, равное пустой строке. Далее мы будем обращаться к messageInput, для взаимодействия с полем ввода.

Добавим к нашему инпуту следующее:

<Input placeholder="Your message..." {...messageInput.register()} />

Таким образом мы регистрируем и привязываем хук к этому полю ввода

1 - Метод Polling

Для каждого из способов нам понадобиться создать свой моковый сервер. В случае с polling воспользуюсь библиотекой от создатиля reactuse - ? Mock Config Server

Установим пакет:

$yarn add mock-config-server --dev

Теперь надо настроить конфигурацию пакета. Создадим файл mock-server.config.js:

/** @type {import('mock-config-server').MockServerConfig} */

let messages = [];

const mockServerConfig = {
  rest: {
    baseUrl: "/api",
    configs: [
      {
        path: "/messages",
        method: "get",
        routes: [{ data: messages }],
      },
      {
        path: "/messages/new",
        method: "post",
        routes: [{ data: { success: "true" } }],
        interceptors: {
          response: (data, { request }) => {
            messages.push({
              text: request.body.text,
              type: "client",
            });
            messages.push({
              text: request.body.text,
              type: "server",
            });

            return data;
          },
        },
      },
    ],
  },
};

export default mockServerConfig;

Здесь мы создаем переменную messages. В ней будем хранить все сообщения. Создадим маршрут /messages и запрос GET. Этот запрос будет просто отдавать переменную messages, равную всем полученным и отправленым сообщениям в массиве. Далее POST запрос /messages/new. Через interceptors создаем функцию, которая будет обрабатываться когда придет этот запрос. В этой функции мы просто перехватываем текст из сообщения и добавляем в переменную messages 2 сообщения с одинаковым текстом: от сервера и от клиента.

Конфигурация мокового эхо-чат апи готова! Запустим сервер:

 npx mock-config-server

Теперь все запросы доступны по http://localhost:31299/api

Вернемся к базовой верстке:

Создадим запрос за получением сообщений с помощью хука useQuery:

import { useField, useQuery, useMutation } from "@siberiacancode/reactuse";
const { data, refetch, isRefetching, isLoading, isError, error } = useQuery(
  () => fetch("<http://localhost:31299/api/messages>").then((res) => res.json()),
  {
    refetchInterval: 1200000,
    keys: ["messages"],
    onSuccess: (data) => setMessages(data),
  }
);

В параметры функции передаем callback функцию запроса и объект options. В нем указываем ключ, refetchInterval, чтобы запрос отправлялся заново автоматически через 120000 милисекунд (20 минут) и onSuccess - в случае успеха **присвоим результат запроса (data) стэйту messages. Получаем ряд стэйтов и функций, которые мы заюзаем позже.

Немного поменяем развертку контента карточки:

<ScrollArea className=" w-full h-72">
  <div className="p-2">
    {isLoading || isRefetching ? (
      <p className="text-center">Wait...</p>
    ) : isError ? (
      <p className="text-center">Error {error?.message}</p>
    ) : (
      messages.map((message, index) => {
        if (message.type === "client")
          return (
            <div className="flex justify-end" key={index}>
              <div className=" bg-slate-800 text-foreground rounded-md p-1.5 max-w-64 shadow-slate-800 shadow-md">
                {message.text}
              </div>
            </div>
          );
        else
          return (
            <div className="flex" key={index}>
              <div className=" bg-slate-300 text-background rounded-md p-1.5 max-w-64 shadow-slate-300 shadow-md">
                {message.text}
              </div>
            </div>
          );
      })
    )}
  </div>
</ScrollArea>;

В случае ошибки отобразим ошибку, в случае загрузки или рефетча данных отображаем Wait… Иначе - отображаем ui

Теперь займемся рефетчем по кнопке. Просто добавим к кнопке Refetch следующее:

<Button onClick={()=>refetch()}>Refetch</Button>

Теперь при нажатии будет происходить ревалидация данных.

Теперь будем отправлять сообщения.

const { mutateAsync } = useMutation(async (text) => {
    await fetch("<http://localhost:31299/api/messages/new>", {
      method: "POST",
      body: JSON.stringify({ text: text }),
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
    });
  });

получаем функцию mutateAsync инициализируя хук useMutation. В callback функцию указываем POST запрос с body и загаловками.

Теперь мы можем вызвать функцию mutateAsync чтобы совершить мутацию данных (отправить на сервер сообщение). Вызывем ее как только пользователь нажмет Send. После отправки ресетнем форму

<Button
  onClick={async () => {
    await mutateAsync(messageInput.getValue());
    messageInput.reset();
  }}
>
  Send
</Button>;

Готово!

Untitled
Untitled

Весь код:

import { useState } from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardTitle } from "./ui/card";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { Separator } from "./ui/separator";
import { useField, useQuery, useMutation } from "@siberiacancode/reactuse";

type Message = {
  text: string;
  type: "client" | "server";
};

const Chat = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  const messageInput = useField({ initialValue: "" });
  const { data, refetch, isRefetching, isLoading, isError, error } = useQuery(
    () =>
      fetch("http://localhost:31299/api/messages").then((res) => res.json()),
    {
      refetchInterval: 1200000,
      keys: ["messages"],
      onSuccess: (data) => setMessages(data),
    }
  );
  const { mutateAsync } = useMutation(async (text) => {
    await fetch("http://localhost:31299/api/messages/new", {
      method: "POST",
      body: JSON.stringify({ text: text }),
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
      },
    });
  });

  return (
    <Card className=" w-80 h-96">
      <CardTitle>
        <div className="p-2 flex justify-between items-center">
          <p>Pokemon Chat</p>
          <Button onClick={() => refetch()}>Refetch</Button>
        </div>
        <Separator />
      </CardTitle>
      <CardContent className="p-0 flex flex-col">
        <ScrollArea className=" w-full h-72">
          <div className="p-2">
            {isLoading || isRefetching ? (
              <p className="text-center">Wait...</p>
            ) : isError ? (
              <p className="text-center">Error {error?.message}</p>
            ) : (
              messages.map((message, index) => {
                if (message.type === "client")
                  return (
                    <div className="flex justify-end" key={index}>
                      <div className=" bg-slate-800 text-foreground rounded-md p-1.5 max-w-64 shadow-slate-800 shadow-md">
                        {message.text}
                      </div>
                    </div>
                  );
                else
                  return (
                    <div className="flex" key={index}>
                      <div className=" bg-slate-300 text-background rounded-md p-1.5 max-w-64 shadow-slate-300 shadow-md">
                        {message.text}
                      </div>
                    </div>
                  );
              })
            )}
          </div>
        </ScrollArea>
        <div className=" flex ">
          <Input placeholder="Your message..." {...messageInput.register()} />
          <Button
            onClick={async () => {
              await mutateAsync(messageInput.getValue());
              messageInput.reset();
            }}
          >
            Send
          </Button>
        </div>
      </CardContent>
    </Card>
  );
};

export default Chat;

Метод с использованием WebSockets

Как и в случае с методом поллинга нам нужно будет использовать моковый сервер, чтобы имитировать реальный бэкенд. Если в случае с поллингом мы использовали mock-config-server, и задавали ему конфигурацию, чтобы получить функционал эхо чата, то в случае с вебсокетами все попроще, так как один из основных сценариев использования вебсокетов - это чаты, то существуют готовые решения специально для этого.

wss://echo.websocket.org - мы будем обращаться по этому запросу, чтобы подключиться к weboscket соединению.

Вернемся к нашей базовой развертке компонента Chat. Добавим подключение к вебсокет серверу:

import { useField, useWebSocket, useMount } from "@siberiacancode/reactuse";
const { send, status, close, open } = useWebSocket(
    "wss://echo.websocket.org",
    {
      onConnected: (webSocket) => console.log(`Connected to ${webSocket.url}`),
    }
  );
useMount(() => console.log("Connecting to websocket server..."));

Здесь мы задали базовую конфигурацию и инициализацию хука. Передали адрес, по которому нужно законнектиться и в options указали, что когда произойдет событие onConnected, мы выведем в консоль сообщение об успешном присоединении к серверу.

Также ниже вызывем хук useMount, он будет кидать в консоль сообщение Connecting… как только компонент будет впервые рендериться.

(далее мы дополним options у хука useWebsocket)

Поменяем поведение кнопки. На клик она будет обрабатывать следующее:

          <Button
            onClick={() => {
              const message = messageInput.getValue();
              setMessages((prevMessages) => [
                ...prevMessages,
                { text: message, type: "client" },
              ]);
              send(message);
              messageInput.reset();
            }}
          >

Сначала мы оптимистично* обновим ui, закинем в стэйт сообщений наше новое сообщение. Компонент сразу же обновится с новым сообщением. Потом отправим сообщение на сервер через вебсокет, затем ресетним input.

*Оптимистично, потому что такое поведение называется Optimistic update. Когда пользователь совершает действие (например нажатие кнопки лайка) и мы сразу обновляем ui, не дожидаясь, когда запрос на это действие дойдет

Теперь когда мы отправляем сообщения они сразу появляются в чате.

Теперь осталось реализовать ответные сообщение от сервера. И здесь все будет попроще.

onMessage: (event) =>
        setMessages((prevMessages) => [
          ...prevMessages,
          { text: event.data, type: "server" },
        ]),

Возвращаемся в options у хука useWebsocket, добавляем, что на событие onMessage мы добавим в стэйт messages сообщение от сервера.

Теперь все готово!

Untitled
Untitled

Весь код:

import { useState } from "react";
import { Button } from "./ui/button";
import { Card, CardContent, CardTitle } from "./ui/card";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { Separator } from "./ui/separator";
import { useField, useMount, useWebSocket } from "@siberiacancode/reactuse";

type Message = {
  text: string;
  type: "client" | "server";
};

const Chat = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  const messageInput = useField({ initialValue: "" });
  const { send, status, close, open } = useWebSocket(
    "wss://echo.websocket.org",
    {
      onConnected: (webSocket) => console.log(`Connected to ${webSocket.url}`),
      onMessage: (event) =>
        setMessages((prevMessages) => [
          ...prevMessages,
          { text: event.data, type: "server" },
        ]),
    }
  );

  useMount(() => console.log("Connecting to websocket server..."));
  return (
    <Card className=" w-80 h-96">
      <CardTitle>
        <div className="p-2">Pokemon Chat</div>
        <Separator />
      </CardTitle>
      <CardContent className="p-0 flex flex-col">
        <ScrollArea className=" w-full h-72">
          <div className="p-2">
            {messages.map((message, index) => {
              if (message.type === "client")
                return (
                  <div className="flex justify-end" key={index}>
                    <div className=" bg-slate-800 text-foreground rounded-md p-1.5 max-w-64 shadow-slate-800 shadow-md">
                      {message.text}
                    </div>
                  </div>
                );
              else
                return (
                  <div className="flex" key={index}>
                    <div className=" bg-slate-300 text-background rounded-md p-1.5 max-w-64 shadow-slate-300 shadow-md">
                      {message.text}
                    </div>
                  </div>
                );
            })}
          </div>
        </ScrollArea>
        <div className=" flex ">
          <Input placeholder="Your message..." {...messageInput.register()} />
          <Button
            onClick={() => {
              const message = messageInput.getValue();
              setMessages((prevMessages) => [
                ...prevMessages,
                { text: message, type: "client" },
              ]);
              send(message);
              messageInput.reset();
            }}
          >
            Send
          </Button>
        </div>
      </CardContent>
    </Card>
  );
};

export default Chat;

Заключение

Как мы смогли увидеть - вебсокеты нужны в ситуациях, когда сообщения должны приходить моментально и моментально обновлять ui, например чаты в мессенджерах. Обычный поллинг мы можем использовать, когда данные не важно получать настолько быстро и их актуальность не столь важна, например, почтовые сервисы.

Эта статья показала вам основные различия и способы применения поллинга и веб сокетов. Лучше всего для этих кейсов использовать хуки и самую огромную коллекцию хуков в react - reactuse.

Комментарии (5)


  1. JerryI
    01.08.2024 10:18
    +2

    Поправьте форматирование, статьи на хабр не надо напофиг скидывать. К тому же при такой длине.

    Форматирование
    Форматирование
    Почему в подпись???
    Почему в подпись???


  1. gudvinr
    01.08.2024 10:18
    +1

    Есть ли смысл использовать polling сейчас, когда есть EventSource и ReadableStream? Конечно, кроме случаев когда нужно что-то древнее поддерживать. Но раз уж у вас реакт, то это итак стильно модно молодежно и такие проблемы вряд ли заботят

    Вот это уже будет нормальное 1:1 сравнение. А сравнения поллинг vs вебсокеты уже миллион раз были


    1. Avangardio
      01.08.2024 10:18

      Ивентсурс - имбалансная вещь, плюс копейки на бэке весит, и реконекты есть, и прочие плюшки. В итоге, достойная технология получилась.


    1. JerryI
      01.08.2024 10:18

      Согласен. Оверхеда полно в обычном polling, вы греете чайников в 10 раз больше. Ну и в целом я думаю от «таймеров» надо уходить, их хватает на уровне TCP и ниже


  1. SolidHard1
    01.08.2024 10:18

    Интересная статья, спасибо!)