Вступление
В современной веб-разработке существует множество способов обновления данных в реальном времени. Среди наиболее распространённых методов выделяются Polling и WebSockets. Оба подхода имеют свои уникальные особенности и применения, что делает их подходящими для различных сценариев. В этой статье мы рассмотрим основные различия между Polling и WebSockets, их преимущества и недостатки, а также приведем примеры использования каждого из них в React приложениях на хуках.
Polling
Polling – это метод, при котором клиент периодически отправляет запросы на сервер для получения обновленных данных. Этот подход прост в реализации, но может быть неэффективен при частых запросах, так как он увеличивает нагрузку на сервер и сеть.
![Untitled](https://habrastorage.org/getpro/habr/upload_files/423/87b/931/42387b931485c80c0562a463eeca1a9f.png)
Рефетч может происходить при нажатии кнопки, по истечению определенного количества времени, или при любом другом событии.
WebSockets
WebSockets – это протокол, который позволяет устанавливать постоянное соединение между клиентом и сервером. Благодаря этому соединению данные могут передаваться в обоих направлениях в реальном времени, что делает WebSockets более эффективным для приложений, требующих мгновенного обновления данных.
![Untitled](https://habrastorage.org/getpro/habr/upload_files/f84/b19/ca4/f84b19ca4bef636bab50466cbf65cee2.png)
Клиент отправляет запрос, а сервер возвращает ему 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](https://habrastorage.org/getpro/habr/upload_files/fde/a37/3b0/fdea373b0d48c5fb41e5c3cbafdbbb0a.png)
Добавим 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](https://habrastorage.org/getpro/habr/upload_files/339/261/e2f/339261e2f69f3e5eff9932f5ac9b285c.png)
Весь код:
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](https://habrastorage.org/getpro/habr/upload_files/47b/e0d/cc5/47be0dcc5f9d36d2d8d4abeeb820d57f.png)
Весь код:
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)
gudvinr
01.08.2024 10:18+1Есть ли смысл использовать polling сейчас, когда есть EventSource и ReadableStream? Конечно, кроме случаев когда нужно что-то древнее поддерживать. Но раз уж у вас реакт, то это итак стильно модно молодежно и такие проблемы вряд ли заботят
Вот это уже будет нормальное 1:1 сравнение. А сравнения поллинг vs вебсокеты уже миллион раз были
Avangardio
01.08.2024 10:18Ивентсурс - имбалансная вещь, плюс копейки на бэке весит, и реконекты есть, и прочие плюшки. В итоге, достойная технология получилась.
JerryI
01.08.2024 10:18Согласен. Оверхеда полно в обычном polling, вы греете чайников в 10 раз больше. Ну и в целом я думаю от «таймеров» надо уходить, их хватает на уровне TCP и ниже
JerryI
Поправьте форматирование, статьи на хабр не надо напофиг скидывать. К тому же при такой длине.