Урок 10

WebSocket и real-time

Для чатов, уведомлений, live-обновлений.

WebSocket API

const ws = new WebSocket('wss://api.example.com/ws');

ws.onopen = () => console.log('соединение открыто');
ws.onmessage = (e) => console.log('сообщение:', e.data);
ws.onerror = (e) => console.error('ошибка:', e);
ws.onclose = () => console.log('соединение закрыто');

ws.send(JSON.stringify({ type: 'subscribe', channel: 'chat' }));
ws.close();

Хук useWebSocket

function useWebSocket(url: string) {
    const [messages, setMessages] = useState<unknown[]>([]);
    const wsRef = useRef<WebSocket | null>(null);

    useEffect(() => {
        const ws = new WebSocket(url);
        wsRef.current = ws;

        ws.onmessage = (e) => {
            setMessages((m) => [...m, JSON.parse(e.data)]);
        };

        return () => ws.close();
    }, [url]);

    const send = useCallback((data: unknown) => {
        wsRef.current?.send(JSON.stringify(data));
    }, []);

    return { messages, send };
}

Переподключение

Сеть пропадает — нужно переподключаться:

function useReconnectingWebSocket(url: string) {
    const wsRef = useRef<WebSocket | null>(null);
    const [connected, setConnected] = useState(false);

    useEffect(() => {
        let retry = 0;
        let timeout: ReturnType<typeof setTimeout>;

        function connect() {
            const ws = new WebSocket(url);
            wsRef.current = ws;

            ws.onopen = () => {
                setConnected(true);
                retry = 0;
            };

            ws.onclose = () => {
                setConnected(false);
                const delay = Math.min(1000 * 2 ** retry, 30000);
                retry++;
                timeout = setTimeout(connect, delay);
            };

            ws.onerror = () => ws.close();
        }

        connect();
        return () => {
            clearTimeout(timeout);
            wsRef.current?.close();
        };
    }, [url]);

    return { connected, ws: wsRef.current };
}

Экспоненциальная задержка: 1s, 2s, 4s, 8s, до 30s.

При фокусе и фоне

Соединение стоит закрывать в фоне и открывать при возврате:

import { AppState } from 'react-native';

useEffect(() => {
    const sub = AppState.addEventListener('change', (state) => {
        if (state === 'active') connect();
        if (state === 'background') disconnect();
    });
    return () => sub.remove();
}, []);

Socket.IO

npm install socket.io-client
import { io } from 'socket.io-client';

const socket = io('https://api.example.com', {
    auth: { token },
    transports: ['websocket'],
});

socket.on('connect', () => console.log('connected'));

socket.on('message', (data) => console.log(data));

socket.emit('join', { room: 'chat' });

socket.disconnect();

Переподключение, комнаты, события — из коробки.

Laravel Echo + Reverb

Если бэкенд на Laravel:

npm install laravel-echo pusher-js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

const echo = new Echo({
    broadcaster: 'reverb',
    key: process.env.EXPO_PUBLIC_REVERB_KEY,
    wsHost: process.env.EXPO_PUBLIC_REVERB_HOST,
    wsPort: 443,
    forceTLS: true,
    authEndpoint: 'https://api.example.com/broadcasting/auth',
    auth: { headers: { Authorization: `Bearer ${token}` } },
});

echo.private(`chat.${roomId}`)
    .listen('MessageSent', (e) => {
        setMessages((m) => [...m, e.message]);
    });

Интеграция с TanStack Query

Обновление кеша из WebSocket:

useEffect(() => {
    const handler = (data: Message) => {
        queryClient.setQueryData(['messages', data.roomId], (old: Message[]) => [...(old ?? []), data]);
    };

    ws.on('message', handler);
    return () => ws.off('message', handler);
}, [queryClient]);

Индикатор соединения

function ChatScreen() {
    const { connected } = useReconnectingWebSocket(url);

    return (
        <>
            {!connected && (
                <View style={styles.banner}>
                    <Text>Соединение потеряно, переподключение...</Text>
                </View>
            )}
            {/* чат */}
        </View>
    );
}

Очередь сообщений офлайн

const [queue, setQueue] = useState<Message[]>([]);

useEffect(() => {
    if (connected && queue.length > 0) {
        queue.forEach((msg) => ws.send(JSON.stringify(msg)));
        setQueue([]);
    }
}, [connected, queue]);

Итоги

  • WebSocket для real-time
  • Переподключение с задержкой
  • AppState для фона
  • Socket.IO упрощает работу
  • Laravel Echo для Laravel
  • Интеграция с TanStack Query
  • Индикатор соединения