Меньшинству, но все же огромному числу проектов требуется интеграция веб-сокетов, чтобы обеспечить мгновенную реакцию интерфейса на изменения без повторной загрузки данных.
Это важная вещь, и мы не собираемся говорить о них или сравнивать сторонние библиотеки, предоставляющие API для лучшего опыта разработки.
Моя цель — показать, как быстро интегрировать @microsoft/signalr
с NextJs. И как решить проблемы, с которыми мы столкнулись во время разработки.
Я надеюсь, что все уже установили и развернули проект NextJS локально. В моем случае версия 13.2.4
. Добавим еще несколько важных библиотек: swr
(версия 2.1.5
) для выборки данных и дальнейшей работы с локальным кешем и @microsoft/signalr
(версия 7.0.5
) — API для веб-сокетов.
npm install --save @microsoft/signalr swr
Давайте начнем с создания простой функции fetcher
и нового хука под названием useChatData
для получения исходных данных из нашего REST API. Он возвращает список сообщений для чата, поля, определяющие ошибки и состояние загрузки, а также метод mutate
, позволяющий изменять кэшированные данные.
// hooks/useChatData.ts import useSWR from 'swr'; type Message = { content: string; createdAt: Date; id: string; }; async function fetcher<TResponse>(url: string, config: RequestInit): Promise<TResponse> { const response = await fetch(url, config); if (!response.ok) { throw response; } return await response.json(); } export const useChatData = () => { const { data, error, isLoading, mutate } = useSWR<Message[]>('OUR_API_URL', fetcher); return { data: data || [], isLoading, isError: error, mutate, }; };
Чтобы проверить, работает ли он должным образом, давайте обновим компонент нашей страницы. Импортируйте наш крючок вверху и извлеките из него данные, как показано в фрагменте ниже. Если это сработает, вы увидите визуализированные данные. Как видите, это довольно просто.
// pages/chat.ts import { useChatData } from 'hooks/useChatData'; const Chat: NextPage = () => { const { data } = useChatData(); return ( <div> {data.map(item => ( <div key={item.id}>{item.content}</div> ))} </div> ); };
Следующий шаг требует подключения нашей будущей страницы к веб-сокетам, перехвата событий NewMessage
и обновления кеша новым сообщением. Предлагаю начать с сборки сервиса сокетов в отдельном файле.
Согласно примерам в документации SignalR, нам необходимо создать экземпляр соединения для дальнейшего прослушивания событий. Я также добавил объект соединений для предотвращения дублирования и двух помощников для запуска/остановки соединений.
// api/socket.ts import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; let connections = {} as { [key: string]: { type: string; connection: HubConnection; started: boolean } }; function createConnection(messageType: string) { const connectionObj = connections[messageType]; if (!connectionObj) { console.log('SOCKET: Registering on server events ', messageType); const connection = new HubConnectionBuilder() .withUrl('API_URL', { logger: LogLevel.Information, withCredentials: false, }) .withAutomaticReconnect() .build(); connections[messageType] = { type: messageType, connection: connection, started: false, }; return connection; } else { return connections[messageType].connection; } } function startConnection(messageType: string) { const connectionObj = connections[messageType]; if (!connectionObj.started) { connectionObj.connection.start().catch(err => console.error('SOCKET: ', err.toString())); connectionObj.started = true; } } function stopConnection(messageType: string) { const connectionObj = connections[messageType]; if (connectionObj) { console.log('SOCKET: Stoping connection ', messageType); connectionObj.connection.stop(); connectionObj.started = false; } } function registerOnServerEvents( messageType: string, callback: (payload: Message) => void, ) { try { const connection = createConnection(messageType); connection.on('NewIncomingMessage', (payload: Message) => { callback(payload); }); connection.onclose(() => stopConnection(messageType)); startConnection(messageType); } catch (error) { console.error('SOCKET: ', error); } } export const socketService = { registerOnServerEvents, stopConnection, };
Итак, теперь наша страница может выглядеть так, как показано в фрагменте кода. Мы извлекаем и извлекаем data
со списком сообщений и отображаем их. Кроме того, useEffect
описанный выше, регистрирует событие NewMessage
, создает соединение и прослушивает серверную часть.
Когда событие срабатывает, метод mutate
из перехватчика обновляет существующий список новым объектом.
// pages/chat.ts import { useChatData } from 'hooks/useChatData'; import { socketService } from 'api/socket'; const Chat: NextPage = () => { const { data } = useChatData(); useEffect(() => { socketService.registerOnServerEvents( 'NewMessage', (payload: Message) => { mutate(() => [...data, payload], { revalidate: false }); } ); }, [data]); useEffect(() => { return () => { socketService.stopConnection('NewMessage'); }; }, []); return ( <div> {data.map(item => ( <div key={item.id}>{item.content}</div> ))} </div> ); };
Мне нравится, работает, и мы видим, как в ленте появляются новые сообщения. Я выбрал базовый пример с чатом, потому что он понятен и понятен. И, конечно, вы применяете это по своей собственной логике.
Используя одну из версий ( @microsoft/signalr
), мы столкнулись с проблемой дублирования. Он был подключен к useEffect
, массиву зависимостей. Каждый раз, когда зависимость менялась, connection.on(event, callback);
кэшировал обратный вызов и запускал его снова и снова.
useEffect(() => { // data equals [] by default (registerOnServerEvents 1 run), // but after initial data fetching it changes (registerOnServerEvents 2 run) // each event changes data and triggers runnning of registerOnServerEvents socketService.registerOnServerEvents( 'NewMessage', // callback cached (payload: Message) => { // mutate called multiple times on each data change mutate(() => [...data, payload], { revalidate: false }); } ); }, [data]); // after getting 3 messages events, we had got 4 messages rendered lol
Самым быстрым и надежным решением, которое мы нашли, было сохранение копии данных внутри ref
React и использование ее внутри useEffect
для будущих обновлений.
// pages/chat.ts import { useChatData } from 'hooks/useChatData'; import { socketService } from 'api/socket'; const Chat: NextPage = () => { const { data } = useChatData(); const messagesRef = useRef<Message[]>([]); useEffect(() => { messagesRef.current = chatData; }, [chatData]); useEffect(() => { socketService.registerOnServerEvents( 'NewMessage', (payload: Message) => { const messagesCopy = messagesRef.current.slice(); mutate(() => [...messagesCopy, payload], { revalidate: false }); } ); }, [data]); useEffect(() => { return () => { socketService.stopConnection('NewMessage'); }; }, []); return ( <div> {data.map(item => ( <div key={item.id}>{item.content}</div> ))} </div> ); };
В настоящее время мы используем новую версию @microsoft/signalr
, в которой, похоже, уже есть необходимые исправления. Но в любом случае, если кто-то найдет это решение полезным и воспользуется этим обходным путем, я буду рад. В заключение хочу сказать, что мой опыт работы с SignalR вполне положительный, для установки не потребовалось каких-то особых зависимостей или настроек, он отлично работает и покрывает наши потребности.