Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
WebSockets i Server-Sent Events w React
W tej lekcji3
Radar stacji ma pokazywać pozycje statków na bieżąco. Zwykłe REST API działa jak list: klient pyta, serwer odpowiada i rozmowa się kończy. Żeby widzieć zmiany, musiałbyś pytać co sekundę, marnując łącze i baterię. Komunikacja w czasie rzeczywistym odwraca ten model: serwer sam wysyła dane, gdy coś się wydarzy. W przeglądarce masz do tego dwie technologie: WebSocket i Server-Sent Events (SSE).
WebSockets - dwukierunkowa komunikacja w czasie rzeczywistym
WebSocket zaczyna jak zwykłe żądanie HTTP, a potem przełącza się na własny protokół (ws:// albo szyfrowany wss://). Od tej chwili obie strony mogą wysyłać wiadomości tekstowe lub binarne w dowolnym momencie. To najlepszy wybór dla czatu, powiadomień na żywo, danych giełdowych i gier, czyli wszędzie, gdzie klient też często nadaje.
Podstawowa implementacja WebSocket
Hook useWebSocket zamyka cały cykl życia połączenia: łączenie, odbiór, wysyłkę, rozłączanie i ponawianie:
1// hooks/useWebSocket.js
2import { useState, useEffect, useRef, useCallback } from 'react';
3
4const useWebSocket = (url, options = {}) => {
5 const [socket, setSocket] = useState(null);
6 const [lastMessage, setLastMessage] = useState(null);
7 const [readyState, setReadyState] = useState(0);
8 const [connectionError, setConnectionError] = useState(null);
9
10 // Gniazdo, listenery i licznik prób w refach - ich zmiana nie wywołuje renderu
11 const socketRef = useRef(null);
12 const messageListeners = useRef(new Set());
13 const retryTimeoutRef = useRef(null);
14 const reconnectAttemptsRef = useRef(0);
15
16 // Najnowsze opcje z ostatniego renderu: callbacki podane inline
17 // nie zmieniają connect, więc efekt nie łączy się od nowa
18 const optionsRef = useRef(options);
19 useEffect(() => {
20 optionsRef.current = options;
21 });
22
23 const { heartbeatInterval = 30000 } = options;
24
25 // Funkcja do tworzenia połączenia - zależy tylko od url
26 const connect = useCallback(() => {
27 const { protocols, reconnectAttempts = 5, reconnectInterval = 3000 } = optionsRef.current;
28
29 try {
30 const ws = new WebSocket(url, protocols);
31 socketRef.current = ws;
32
33 ws.onopen = (event) => {
34 console.log('WebSocket połączony:', url);
35 setReadyState(1); // OPEN
36 setConnectionError(null);
37 reconnectAttemptsRef.current = 0;
38 optionsRef.current.onOpen?.(event);
39 };
40
41 ws.onmessage = (event) => {
42 let parsedData;
43 try {
44 parsedData = JSON.parse(event.data);
45 } catch {
46 parsedData = event.data;
47 }
48
49 setLastMessage({
50 data: parsedData,
51 timestamp: Date.now(),
52 raw: event.data
53 });
54
55 // Wywołaj wszystkie zarejestrowane listenery
56 messageListeners.current.forEach(listener => {
57 listener(parsedData, event);
58 });
59
60 optionsRef.current.onMessage?.(parsedData, event);
61 };
62
63 ws.onclose = (event) => {
64 // Zdarzenia starego gniazda (po reconnect albo odmontowaniu) pomijamy
65 if (socketRef.current !== ws) return;
66
67 console.log('WebSocket rozłączony:', event.code, event.reason);
68 setReadyState(3); // CLOSED
69 setSocket(null);
70 optionsRef.current.onClose?.(event);
71
72 // Automatyczne ponowne łączenie
73 if (!event.wasClean && reconnectAttemptsRef.current < reconnectAttempts) {
74 reconnectAttemptsRef.current++;
75 console.log(`Ponowne łączenie (${reconnectAttemptsRef.current}/${reconnectAttempts})...`);
76
77 retryTimeoutRef.current = setTimeout(() => {
78 connect();
79 }, reconnectInterval * reconnectAttemptsRef.current);
80 }
81 };
82
83 ws.onerror = (error) => {
84 if (socketRef.current !== ws) return;
85 console.error('WebSocket błąd:', error);
86 setConnectionError(error);
87 optionsRef.current.onError?.(error);
88 };
89
90 setSocket(ws);
91 setReadyState(0); // CONNECTING
92
93 } catch (error) {
94 console.error('Nie udało się utworzyć WebSocket:', error);
95 setConnectionError(error);
96 }
97 }, [url]);
98
99 // Wysyłanie wiadomości
100 const sendMessage = useCallback((message) => {
101 const ws = socketRef.current;
102 if (ws && ws.readyState === WebSocket.OPEN) {
103 const dataToSend = typeof message === 'string' ? message : JSON.stringify(message);
104 ws.send(dataToSend);
105 return true;
106 } else {
107 console.warn('WebSocket nie jest połączony');
108 return false;
109 }
110 }, []);
111
112 // Dodawanie listenera wiadomości
113 const addMessageListener = useCallback((listener) => {
114 messageListeners.current.add(listener);
115 return () => messageListeners.current.delete(listener);
116 }, []);
117
118 // Ręczne połączenie
119 const reconnect = useCallback(() => {
120 clearTimeout(retryTimeoutRef.current);
121 socketRef.current?.close();
122 reconnectAttemptsRef.current = 0;
123 connect();
124 }, [connect]);
125
126 // Rozłączenie
127 const disconnect = useCallback(() => {
128 clearTimeout(retryTimeoutRef.current);
129 socketRef.current?.close(1000, 'Zamknięte przez użytkownika');
130 }, []);
131
132 // Automatyczne połączenie przy mount
133 useEffect(() => {
134 connect();
135
136 return () => {
137 clearTimeout(retryTimeoutRef.current);
138 const ws = socketRef.current;
139 socketRef.current = null; // onclose starego gniazda nic już nie zrobi
140 ws?.close();
141 };
142 }, [connect]);
143
144 // Heartbeat - ping/pong
145 useEffect(() => {
146 if (socket && readyState === 1 && heartbeatInterval > 0) {
147 const interval = setInterval(() => {
148 if (socket.readyState === WebSocket.OPEN) {
149 sendMessage({ type: 'ping', timestamp: Date.now() });
150 }
151 }, heartbeatInterval);
152
153 return () => clearInterval(interval);
154 }
155 }, [socket, readyState, heartbeatInterval, sendMessage]);
156
157 return {
158 socket,
159 lastMessage,
160 readyState,
161 connectionError,
162 sendMessage,
163 addMessageListener,
164 reconnect,
165 disconnect,
166 isConnecting: readyState === 0,
167 isOpen: readyState === 1,
168 isClosing: readyState === 2,
169 isClosed: readyState === 3
170 };
171};
172
173export default useWebSocket;readyState odwzorowuje stany gniazda (0 łączenie, 1 otwarte, 2 zamykanie, 3 zamknięte), więc wskaźnik statusu masz za darmo. sendMessage wysyła tylko przy otwartym połączeniu, a po nieczystym zamknięciu hook ponawia próbę z rosnącym odstępem. Heartbeat co 30 sekund wysyła ping, żeby pośrednicy nie zerwali bezczynnego łącza.
Hook omija dwie pułapki. Callbacki, jak onOpen podany inline w czacie poniżej, i tablica protocols są nowe przy każdym renderze, więc gdyby connect od nich zależał, efekt otwierałby połączenie od nowa po każdym renderze. Dlatego hook czyta opcje z optionsRef, odświeżanego po każdym renderze, a connect zależy tylko od url. Samo gniazdo leży w socketRef, bo cleanup widziałby inaczej socket z renderu, w którym efekt wystartował, czyli null. Zdarzenia starego gniazda, zamkniętego przez reconnect albo przy odmontowaniu, hook pomija, więc nie wznowi połączenia po wyjściu z czatu.
Czat w czasie rzeczywistym
ChatRoom po otwarciu połączenia wysyła wiadomość join, a każdą odebraną wiadomość rozdziela instrukcją switch według pola type:
1// components/ChatRoom.jsx
2import React, { useState, useEffect, useRef } from 'react';
3import useWebSocket from '../hooks/useWebSocket';
4
5const ChatRoom = ({ roomId, userId, userName }) => {
6 const [messages, setMessages] = useState([]);
7 const [newMessage, setNewMessage] = useState('');
8 const [typingUsers, setTypingUsers] = useState(new Set());
9 const [onlineUsers, setOnlineUsers] = useState(new Map());
10 const messagesEndRef = useRef(null);
11 const typingTimeoutRef = useRef(null);
12
13 const {
14 sendMessage,
15 lastMessage,
16 isOpen,
17 connectionError,
18 addMessageListener
19 } = useWebSocket(`ws://localhost:8080/chat/${roomId}`, {
20 onOpen: () => {
21 // Dołącz do pokoju
22 sendMessage({
23 type: 'join',
24 roomId,
25 userId,
26 userName
27 });
28 }
29 });
30
31 // Obsługa otrzymanych wiadomości
32 useEffect(() => {
33 if (!lastMessage) return;
34
35 const { data } = lastMessage;
36
37 switch (data.type) {
38 case 'message':
39 setMessages(prev => [...prev, {
40 id: data.id,
41 text: data.text,
42 userId: data.userId,
43 userName: data.userName,
44 timestamp: data.timestamp
45 }]);
46 break;
47
48 case 'typing':
49 setTypingUsers(prev => {
50 const newSet = new Set(prev);
51 if (data.isTyping && data.userId !== userId) {
52 newSet.add(data.userName);
53 } else {
54 newSet.delete(data.userName);
55 }
56 return newSet;
57 });
58 break;
59
60 case 'userJoined':
61 setOnlineUsers(prev => new Map(prev).set(data.userId, data.userName));
62 setMessages(prev => [...prev, {
63 id: `system-${Date.now()}`,
64 text: `${data.userName} dołączył do czatu`,
65 type: 'system',
66 timestamp: Date.now()
67 }]);
68 break;
69
70 case 'userLeft':
71 setOnlineUsers(prev => {
72 const newMap = new Map(prev);
73 newMap.delete(data.userId);
74 return newMap;
75 });
76 setMessages(prev => [...prev, {
77 id: `system-${Date.now()}`,
78 text: `${data.userName} opuścił czat`,
79 type: 'system',
80 timestamp: Date.now()
81 }]);
82 break;
83
84 case 'messageDeleted':
85 setMessages(prev => prev.filter(msg => msg.id !== data.messageId));
86 break;
87
88 case 'messageEdited':
89 setMessages(prev => prev.map(msg =>
90 msg.id === data.messageId
91 ? { ...msg, text: data.newText, edited: true }
92 : msg
93 ));
94 break;
95 }
96 }, [lastMessage, userId]);
97
98 // Auto-scroll do najnowszej wiadomości
99 useEffect(() => {
100 messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
101 }, [messages]);
102
103 // Wysyłanie wiadomości
104 const handleSendMessage = (e) => {
105 e.preventDefault();
106
107 if (newMessage.trim() && isOpen) {
108 sendMessage({
109 type: 'message',
110 text: newMessage.trim(),
111 roomId,
112 userId,
113 userName
114 });
115
116 setNewMessage('');
117 handleStopTyping();
118 }
119 };
120
121 // Obsługa pisania
122 const handleTyping = (e) => {
123 setNewMessage(e.target.value);
124
125 if (!typingTimeoutRef.current) {
126 sendMessage({
127 type: 'typing',
128 isTyping: true,
129 userId,
130 userName,
131 roomId
132 });
133 }
134
135 clearTimeout(typingTimeoutRef.current);
136 typingTimeoutRef.current = setTimeout(() => {
137 handleStopTyping();
138 }, 2000);
139 };
140
141 const handleStopTyping = () => {
142 if (typingTimeoutRef.current) {
143 clearTimeout(typingTimeoutRef.current);
144 typingTimeoutRef.current = null;
145
146 sendMessage({
147 type: 'typing',
148 isTyping: false,
149 userId,
150 userName,
151 roomId
152 });
153 }
154 };
155
156 // Usuwanie wiadomości
157 const handleDeleteMessage = (messageId) => {
158 sendMessage({
159 type: 'deleteMessage',
160 messageId,
161 userId
162 });
163 };
164
165 if (connectionError) {
166 return (
167 <div className="chat-error">
168 <h3>Błąd połączenia z czatem</h3>
169 <p>{connectionError.message}</p>
170 <button onClick={() => window.location.reload()}>
171 Spróbuj ponownie
172 </button>
173 </div>
174 );
175 }
176
177 return (
178 <div className="chat-room">
179 <div className="chat-header">
180 <h2>Pokój: {roomId}</h2>
181 <div className="online-users">
182 Online ({onlineUsers.size}): {Array.from(onlineUsers.values()).join(', ')}
183 </div>
184 <div className="connection-status">
185 Status: {isOpen ? 'Połączony' : 'Rozłączony'}
186 </div>
187 </div>
188
189 <div className="chat-messages">
190 {messages.map(message => (
191 <div
192 key={message.id}
193 className={`message ${message.type === 'system' ? 'system' : ''} ${message.userId === userId ? 'own' : ''}`}
194 >
195 {message.type !== 'system' && (
196 <div className="message-header">
197 <span className="username">{message.userName}</span>
198 <span className="timestamp">
199 {new Date(message.timestamp).toLocaleTimeString()}
200 </span>
201 {message.userId === userId && (
202 <button
203 onClick={() => handleDeleteMessage(message.id)}
204 className="delete-btn"
205 >
206 Usuń
207 </button>
208 )}
209 </div>
210 )}
211 <div className="message-text">
212 {message.text}
213 {message.edited && <span className="edited"> (edytowane)</span>}
214 </div>
215 </div>
216 ))}
217
218 {typingUsers.size > 0 && (
219 <div className="typing-indicator">
220 {Array.from(typingUsers).join(', ')} pisze...
221 </div>
222 )}
223
224 <div ref={messagesEndRef} />
225 </div>
226
227 <form onSubmit={handleSendMessage} className="chat-input">
228 <input
229 type="text"
230 value={newMessage}
231 onChange={handleTyping}
232 onBlur={handleStopTyping}
233 disabled={!isOpen}
234 maxLength={500}
235 />
236 <button type="submit" disabled={!isOpen || !newMessage.trim()}>
237 Wyślij
238 </button>
239 </form>
240 </div>
241 );
242};
243
244export default ChatRoom;Typy typing, userJoined i messageEdited to protokół umówiony z serwerem, a nie część standardu WebSocket. Wskaźnik pisania wysyła isTyping: false po dwóch sekundach ciszy, więc nie zalewa serwera wiadomościami.
Middleware i autoryzacja
Wraz z rozwojem czatu przybywa reguł dla wiadomości. WebSocketMiddleware przepuszcza je przez łańcuch funkcji, jak celnik na śluzie:
1// utils/websocketMiddleware.js
2class WebSocketMiddleware {
3 constructor() {
4 this.middlewares = [];
5 }
6
7 use(middleware) {
8 this.middlewares.push(middleware);
9 }
10
11 async processIncoming(message, socket) {
12 let processedMessage = message;
13
14 for (const middleware of this.middlewares) {
15 try {
16 processedMessage = await middleware.incoming(processedMessage, socket);
17 if (!processedMessage) break; // Middleware może zatrzymać przetwarzanie
18 } catch (error) {
19 console.error('Middleware error (incoming):', error);
20 break;
21 }
22 }
23
24 return processedMessage;
25 }
26
27 async processOutgoing(message, socket) {
28 let processedMessage = message;
29
30 for (const middleware of this.middlewares) {
31 try {
32 processedMessage = await middleware.outgoing(processedMessage, socket);
33 if (!processedMessage) break;
34 } catch (error) {
35 console.error('Middleware error (outgoing):', error);
36 break;
37 }
38 }
39
40 return processedMessage;
41 }
42}
43
44// Middleware do autentykacji
45const authMiddleware = {
46 incoming: async (message, socket) => {
47 if (message.type === 'auth') {
48 const token = message.token;
49 const isValid = await validateToken(token);
50
51 if (isValid) {
52 socket.isAuthenticated = true;
53 socket.userId = getUserIdFromToken(token);
54 return { type: 'authSuccess', userId: socket.userId };
55 } else {
56 socket.close(4001, 'Unauthorized');
57 return null;
58 }
59 }
60
61 if (!socket.isAuthenticated && message.type !== 'auth') {
62 socket.send(JSON.stringify({
63 type: 'error',
64 message: 'Authentication required'
65 }));
66 return null;
67 }
68
69 return message;
70 },
71
72 outgoing: async (message, socket) => {
73 if (!socket.isAuthenticated && message.type !== 'authRequired') {
74 return null;
75 }
76 return message;
77 }
78};
79
80// Middleware do rate limiting
81const rateLimitMiddleware = {
82 incoming: async (message, socket) => {
83 if (!socket.messageCount) {
84 socket.messageCount = 0;
85 socket.lastReset = Date.now();
86 }
87
88 const now = Date.now();
89 const timePassed = now - socket.lastReset;
90
91 // Reset co minutę
92 if (timePassed > 60000) {
93 socket.messageCount = 0;
94 socket.lastReset = now;
95 }
96
97 socket.messageCount++;
98
99 // Maksymalnie 100 wiadomości na minutę
100 if (socket.messageCount > 100) {
101 socket.send(JSON.stringify({
102 type: 'error',
103 message: 'Rate limit exceeded'
104 }));
105 return null;
106 }
107
108 return message;
109 },
110
111 outgoing: async (message, socket) => message
112};
113
114// Hook z middleware
115const useAdvancedWebSocket = (url, options = {}) => {
116 // Middleware rejestrujemy raz, przy pierwszym renderze (efekt StrictMode uruchomiłby dwa razy)
117 const middleware = useRef(null);
118 if (middleware.current === null) {
119 middleware.current = new WebSocketMiddleware();
120 middleware.current.use(authMiddleware);
121 middleware.current.use(rateLimitMiddleware);
122 }
123
124 const { sendMessage: baseSendMessage, socket, ...rest } = useWebSocket(url, {
125 ...options,
126 onMessage: async (data, event) => {
127 const processed = await middleware.current.processIncoming(data, event.target);
128 if (processed) {
129 options.onMessage?.(processed, event);
130 }
131 }
132 });
133
134 const sendMessage = useCallback(async (message) => {
135 if (socket) {
136 const processed = await middleware.current.processOutgoing(message, socket);
137 if (processed) {
138 return baseSendMessage(processed);
139 }
140 }
141 return false;
142 }, [socket, baseSendMessage]);
143
144 return {
145 ...rest,
146 sendMessage,
147 socket
148 };
149};Middleware zwracający null zatrzymuje wiadomość. useAdvancedWebSocket rejestruje middleware raz, przy pierwszym renderze, a gniazdo bierze z useWebSocket. Prawdziwa autoryzacja i limitowanie muszą jednak działać na serwerze, bo kod w przeglądarce użytkownik może zmienić, a po stronie klienta taki middleware tylko porządkuje ruch.
Server-Sent Events (SSE) - jednostronna komunikacja
SSE to zwykła odpowiedź HTTP z typem text/event-stream, której serwer nie zamyka, tylko dopisuje kolejne zdarzenia. Dane płyną wyłącznie od serwera do klienta i tylko jako tekst. W zamian przeglądarkowy EventSource sam wznawia zerwane połączenie i wysyła nagłówek Last-Event-ID, żeby nie zgubić zdarzeń. Hook useServerSentEvents opakowuje go i obsługuje nazwane zdarzenia:
1// hooks/useServerSentEvents.js
2import { useState, useEffect, useRef, useCallback } from 'react';
3
4const useServerSentEvents = (url, options = {}) => {
5 const [data, setData] = useState(null);
6 const [readyState, setReadyState] = useState(0);
7 const [error, setError] = useState(null);
8 const eventSourceRef = useRef(null);
9 const listenersRef = useRef(new Map());
10
11 // Najnowsze opcje bez ponownego łączenia, jak w useWebSocket
12 const optionsRef = useRef(options);
13 useEffect(() => {
14 optionsRef.current = options;
15 });
16
17 const { withCredentials = false } = options;
18
19 const connect = useCallback(() => {
20 try {
21 const eventSource = new EventSource(url, { withCredentials });
22 eventSourceRef.current = eventSource;
23
24 eventSource.onopen = (event) => {
25 console.log('SSE połączone:', url);
26 setReadyState(1); // OPEN
27 setError(null);
28 optionsRef.current.onOpen?.(event);
29 };
30
31 eventSource.onmessage = (event) => {
32 let parsedData;
33 try {
34 parsedData = JSON.parse(event.data);
35 } catch {
36 parsedData = event.data;
37 }
38
39 setData({
40 data: parsedData,
41 timestamp: Date.now(),
42 id: event.lastEventId,
43 type: event.type
44 });
45
46 optionsRef.current.onMessage?.(parsedData, event);
47 };
48
49 eventSource.onerror = (event) => {
50 console.error('SSE błąd:', event);
51 setReadyState(eventSource.readyState); // 0 = przeglądarka łączy się ponownie, 2 = zamknięte
52 setError(event);
53 optionsRef.current.onError?.(event);
54 };
55
56 // Dodaj custom event listeners
57 listenersRef.current.forEach((listener, eventType) => {
58 eventSource.addEventListener(eventType, listener);
59 });
60
61 } catch (err) {
62 console.error('Nie udało się utworzyć EventSource:', err);
63 setError(err);
64 }
65 }, [url, withCredentials]);
66
67 const addEventListener = useCallback((eventType, listener) => {
68 listenersRef.current.set(eventType, listener);
69
70 if (eventSourceRef.current) {
71 eventSourceRef.current.addEventListener(eventType, listener);
72 }
73
74 return () => {
75 listenersRef.current.delete(eventType);
76 if (eventSourceRef.current) {
77 eventSourceRef.current.removeEventListener(eventType, listener);
78 }
79 };
80 }, []);
81
82 const close = useCallback(() => {
83 if (eventSourceRef.current) {
84 eventSourceRef.current.close();
85 eventSourceRef.current = null;
86 setReadyState(2);
87 }
88 }, []);
89
90 useEffect(() => {
91 connect();
92
93 return () => {
94 close();
95 };
96 }, [connect, close]);
97
98 return {
99 data,
100 readyState,
101 error,
102 addEventListener,
103 close,
104 reconnect: connect,
105 isConnecting: readyState === 0,
106 isOpen: readyState === 1,
107 isClosed: readyState === 2
108 };
109};
110
111export default useServerSentEvents;onmessage łapie zdarzenia bez nazwy, a addEventListener('metrics', ...) te z polem event: metrics. W onerror hook zapisuje eventSource.readyState: 0 oznacza, że przeglądarka sama łączy się ponownie, a 2, że połączenie zamknięto na dobre. O ponowieniach decyduje przeglądarka i pole retry: od serwera, więc hook nie liczy prób sam. Opcje czyta z optionsRef, tak jak useWebSocket, więc onMessage podany inline nie otwiera połączenia od nowa przy każdym renderze.
LiveDashboard nasłuchuje czterech nazwanych zdarzeń: metryk, powiadomień, statusu i alertów:
1// components/LiveDashboard.jsx
2import React, { useState, useEffect } from 'react';
3import useServerSentEvents from '../hooks/useServerSentEvents';
4
5const LiveDashboard = () => {
6 const [metrics, setMetrics] = useState({
7 activeUsers: 0,
8 serverLoad: 0,
9 memoryUsage: 0,
10 requestsPerSecond: 0
11 });
12
13 const [notifications, setNotifications] = useState([]);
14 const [systemStatus, setSystemStatus] = useState('healthy');
15 const [lastUpdate, setLastUpdate] = useState(null);
16
17 const { data, isOpen, error, addEventListener } = useServerSentEvents(
18 '/api/dashboard/stream',
19 {
20 onMessage: (data) => {
21 setLastUpdate(new Date());
22 }
23 }
24 );
25
26 // Handler dla różnych typów eventów
27 useEffect(() => {
28 const removeMetricsListener = addEventListener('metrics', (event) => {
29 const metricsData = JSON.parse(event.data);
30 setMetrics(metricsData);
31 });
32
33 const removeNotificationListener = addEventListener('notification', (event) => {
34 const notification = JSON.parse(event.data);
35 setNotifications(prev => [notification, ...prev.slice(0, 9)]); // Maksymalnie 10 notyfikacji
36 });
37
38 const removeStatusListener = addEventListener('status', (event) => {
39 const status = JSON.parse(event.data);
40 setSystemStatus(status.level);
41 });
42
43 const removeAlertListener = addEventListener('alert', (event) => {
44 const alert = JSON.parse(event.data);
45
46 // Pokazuj alert jako browser notification
47 if (Notification.permission === 'granted') {
48 new Notification(`System Alert: ${alert.type}`, {
49 body: alert.message,
50 icon: '/icons/alert.png'
51 });
52 }
53
54 setNotifications(prev => [{
55 ...alert,
56 type: 'alert',
57 timestamp: Date.now()
58 }, ...prev.slice(0, 9)]);
59 });
60
61 return () => {
62 removeMetricsListener();
63 removeNotificationListener();
64 removeStatusListener();
65 removeAlertListener();
66 };
67 }, [addEventListener]);
68
69 // O zgodę pytaj po kliknięciu: Firefox i Safari ignorują prośbę bez gestu użytkownika
70 const [notificationPermission, setNotificationPermission] = useState(Notification.permission);
71
72 const requestNotifications = async () => {
73 setNotificationPermission(await Notification.requestPermission());
74 };
75
76 const getStatusColor = (status) => {
77 switch (status) {
78 case 'healthy': return '#4CAF50';
79 case 'warning': return '#FF9800';
80 case 'critical': return '#F44336';
81 default: return '#9E9E9E';
82 }
83 };
84
85 const getMetricColor = (value, thresholds) => {
86 if (value >= thresholds.critical) return '#F44336';
87 if (value >= thresholds.warning) return '#FF9800';
88 return '#4CAF50';
89 };
90
91 if (error) {
92 return (
93 <div className="dashboard-error">
94 <h2>Błąd połączenia z dashboard</h2>
95 <p>Nie można nawiązać połączenia z serwerem monitoringu.</p>
96 <button onClick={() => window.location.reload()}>
97 Odśwież stronę
98 </button>
99 </div>
100 );
101 }
102
103 return (
104 <div className="live-dashboard">
105 <header className="dashboard-header">
106 <h1>Live Dashboard</h1>
107 <div className="connection-info">
108 <span
109 className={`status-indicator ${isOpen ? 'connected' : 'disconnected'}`}
110 style={{ backgroundColor: isOpen ? '#4CAF50' : '#F44336' }}
111 />
112 {isOpen ? 'Połączony' : 'Rozłączony'}
113 {lastUpdate && (
114 <span className="last-update">
115 Ostatnia aktualizacja: {lastUpdate.toLocaleTimeString()}
116 </span>
117 )}
118 </div>
119 {notificationPermission === 'default' && (
120 <button onClick={requestNotifications}>Włącz powiadomienia</button>
121 )}
122 </header>
123
124 <div className="metrics-grid">
125 <div className="metric-card">
126 <h3>Aktywni użytkownicy</h3>
127 <div
128 className="metric-value"
129 style={{ color: getMetricColor(metrics.activeUsers, { warning: 1000, critical: 1500 }) }}
130 >
131 {metrics.activeUsers.toLocaleString()}
132 </div>
133 </div>
134
135 <div className="metric-card">
136 <h3>Obciążenie serwera</h3>
137 <div
138 className="metric-value"
139 style={{ color: getMetricColor(metrics.serverLoad, { warning: 70, critical: 90 }) }}
140 >
141 {metrics.serverLoad}%
142 </div>
143 <div className="progress-bar">
144 <div
145 className="progress-fill"
146 style={{
147 width: `${metrics.serverLoad}%`,
148 backgroundColor: getMetricColor(metrics.serverLoad, { warning: 70, critical: 90 })
149 }}
150 />
151 </div>
152 </div>
153
154 <div className="metric-card">
155 <h3>Użycie pamięci</h3>
156 <div
157 className="metric-value"
158 style={{ color: getMetricColor(metrics.memoryUsage, { warning: 80, critical: 95 }) }}
159 >
160 {metrics.memoryUsage}%
161 </div>
162 <div className="progress-bar">
163 <div
164 className="progress-fill"
165 style={{
166 width: `${metrics.memoryUsage}%`,
167 backgroundColor: getMetricColor(metrics.memoryUsage, { warning: 80, critical: 95 })
168 }}
169 />
170 </div>
171 </div>
172
173 <div className="metric-card">
174 <h3>Żądania/sek</h3>
175 <div
176 className="metric-value"
177 style={{ color: getMetricColor(metrics.requestsPerSecond, { warning: 1000, critical: 2000 }) }}
178 >
179 {metrics.requestsPerSecond}
180 </div>
181 </div>
182 </div>
183
184 <div className="system-status">
185 <h3>Status systemu</h3>
186 <div
187 className="status-badge"
188 style={{
189 backgroundColor: getStatusColor(systemStatus),
190 color: 'white',
191 padding: '8px 16px',
192 borderRadius: '20px',
193 display: 'inline-block'
194 }}
195 >
196 {systemStatus.toUpperCase()}
197 </div>
198 </div>
199
200 <div className="notifications-panel">
201 <h3>Powiadomienia ({notifications.length})</h3>
202 <div className="notifications-list">
203 {notifications.map((notification, index) => (
204 <div
205 key={index}
206 className={`notification ${notification.type || 'info'}`}
207 >
208 <div className="notification-content">
209 <strong>{notification.title || notification.type}</strong>
210 <p>{notification.message}</p>
211 </div>
212 <div className="notification-time">
213 {new Date(notification.timestamp).toLocaleTimeString()}
214 </div>
215 </div>
216 ))}
217 </div>
218 </div>
219 </div>
220 );
221};
222
223export default LiveDashboard;Każdy listener zwraca funkcję sprzątającą, więc cleanup efektu odpina je wszystkie. Alert trafia też do systemowego powiadomienia, jeśli użytkownik wcześniej się na nie zgodził. O zgodę prosi przycisk, bo Firefox i Safari ignorują prośbę o uprawnienie, która nie wynika z gestu użytkownika.
Wybór między WebSockets a SSE
Kiedy używać WebSockets
Edytor we współpracy to podręcznikowy przypadek WebSocket: każdy użytkownik nadaje zmiany tekstu i ruchy kursora, a jednocześnie odbiera cudze:
1// Przykład: Aplikacja do kolaboracji w czasie rzeczywistym
2const CollaborativeEditor = () => {
3 const { sendMessage, lastMessage, isOpen } = useWebSocket('/ws/document/123');
4 const [documentContent, setDocumentContent] = useState('');
5 const [cursors, setCursors] = useState(new Map());
6
7 const handleTextChange = (newContent, position) => {
8 setDocumentContent(newContent);
9
10 // Wyślij zmianę do innych użytkowników
11 sendMessage({
12 type: 'content_change',
13 content: newContent,
14 position,
15 userId: currentUser.id
16 });
17 };
18
19 const handleCursorMove = (position) => {
20 sendMessage({
21 type: 'cursor_move',
22 position,
23 userId: currentUser.id
24 });
25 };
26
27 useEffect(() => {
28 if (lastMessage) {
29 switch (lastMessage.data.type) {
30 case 'content_change':
31 setDocumentContent(lastMessage.data.content);
32 break;
33 case 'cursor_move':
34 setCursors(prev => new Map(prev).set(
35 lastMessage.data.userId,
36 lastMessage.data.position
37 ));
38 break;
39 }
40 }
41 }, [lastMessage]);
42
43 return (
44 <div className="collaborative-editor">
45 {/* Edytor z real-time collaboration */}
46 </div>
47 );
48};Ruch płynie w obie strony wiele razy na sekundę, więc jedno trwałe połączenie wygrywa z serią osobnych żądań HTTP.
Kiedy używać SSE
Kanał wiadomości to odwrotny przypadek: czytelnik tylko odbiera nowe artykuły, więc wystarczy SSE:
1// Przykład: Live feed aktualności
2const NewsFeed = () => {
3 const [articles, setArticles] = useState([]);
4 const [newArticleCount, setNewArticleCount] = useState(0);
5
6 const { data, addEventListener } = useServerSentEvents('/api/news/stream');
7
8 useEffect(() => {
9 const removeArticleListener = addEventListener('new_article', (event) => {
10 const article = JSON.parse(event.data);
11 setArticles(prev => [article, ...prev]);
12 setNewArticleCount(prev => prev + 1);
13 });
14
15 const removeUpdateListener = addEventListener('article_update', (event) => {
16 const updatedArticle = JSON.parse(event.data);
17 setArticles(prev => prev.map(article =>
18 article.id === updatedArticle.id ? updatedArticle : article
19 ));
20 });
21
22 return () => {
23 removeArticleListener();
24 removeUpdateListener();
25 };
26 }, [addEventListener]);
27
28 return (
29 <div className="news-feed">
30 {newArticleCount > 0 && (
31 <div className="new-articles-banner">
32 {newArticleCount} nowych artykułów
33 <button onClick={() => setNewArticleCount(0)}>
34 Oznacz jako przeczytane
35 </button>
36 </div>
37 )}
38
39 {articles.map(article => (
40 <ArticleCard key={article.id} article={article} />
41 ))}
42 </div>
43 );
44};Nie ma tu żadnego sendMessage. Jeśli klient czasem coś wysyła, na przykład oznacza artykuł jako przeczytany, zrobi to zwykłym żądaniem HTTP.
Porównanie i najlepsze praktyki
Na koniec pomocnik, który wybiera technologię na podstawie wymagań:
1// Wybór technologii na podstawie wymagań - zwykła funkcja, nie hook
2const chooseTransport = ({
3 bidirectional = false,
4 authentication = false,
5 highFrequency = false,
6 binaryData = false
7} = {}) =>
8 bidirectional || authentication || highFrequency || binaryData ? 'websocket' : 'sse';
9
10// Każdy kanał to osobny komponent, który zawsze wywołuje ten sam hook
11function WebSocketChannel({ url }) {
12 const { sendMessage, lastMessage } = useWebSocket(url.replace('http', 'ws'));
13 // Logika dla WebSocket
14 return <div>Kanał WebSocket</div>;
15}
16
17function SseChannel({ url }) {
18 const { data, addEventListener } = useServerSentEvents(url);
19 // Logika dla SSE
20 return <div>Kanał SSE</div>;
21}
22
23// Przykład użycia
24const SmartComponent = () => {
25 const transport = chooseTransport({
26 bidirectional: true, // Wymaga WebSocket
27 authentication: true
28 });
29
30 return transport === 'websocket'
31 ? <WebSocketChannel url="/api/data" />
32 : <SseChannel url="/api/data" />;
33};authentication wskazuje WebSocket, bo EventSource nie pozwala dodać własnych nagłówków, jak Authorization. Sam wybór to zwykła funkcja, a hooki wywołują osobne komponenty, więc każdy z nich woła zawsze ten sam hook w tej samej kolejności, czego wymagają zasady hooków. Warunkowe wywołanie useWebSocket albo useServerSentEvents w jednym komponencie te zasady łamie.
Moja rada: gdy dane płyną tylko od serwera, zacznij od SSE, bo to zwykłe HTTP z automatycznym wznawianiem. Po WebSocket sięgaj, gdy klient też często nadaje. W ćwiczeniu zbudujesz własny hook z połączeniem, rozłączaniem, wysyłką i wskaźnikiem statusu.
Pamiętaj: WebSocket to dwukierunkowy kanał radiowy, a SSE to nadajnik, który tylko nadaje - wybierz ten, który pasuje do rozmowy.
Kod do tej lekcji: App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3// Symulacja serwera WebSocket: strumień telemetrii statku i odpowiedzi na komendy klienta
4// Prawdziwy kod użyłby new WebSocket(url) z handlerami onopen, onmessage i onclose
5class MockWebSocketServer {
6 constructor() {
7 this.handlers = null;
8 this.openTimer = null;
9 this.interval = null;
10 }
11
12 connect(handlers) {
13 this.handlers = handlers;
14 this.openTimer = setTimeout(() => {
15 handlers.onOpen();
16 this.interval = setInterval(() => {
17 handlers.onMessage({
18 type: 'TELEMETRY',
19 timestamp: Date.now(),
20 data: {
21 speed: (27600 + Math.random() * 400).toFixed(0),
22 altitude: (408 + Math.random() * 2).toFixed(1),
23 fuel: (85 - Math.random() * 0.5).toFixed(1),
24 temperature: (20 + Math.random() * 3).toFixed(1),
25 oxygen: (98.5 + Math.random() * 1.5).toFixed(1),
26 },
27 });
28 }, 1500);
29 }, 400);
30 }
31
32 // Serwer odpowiada na komendę klienta - komunikacja w obie strony
33 send(message) {
34 setTimeout(() => {
35 this.handlers?.onMessage({ type: 'ACK', command: message.command, timestamp: Date.now() });
36 }, 300);
37 }
38
39 // Zerwane łącze: zamknięcie bez czystego zakończenia (wasClean: false)
40 drop() {
41 const handlers = this.handlers;
42 this.stop();
43 handlers?.onClose({ wasClean: false });
44 }
45
46 close() {
47 const handlers = this.handlers;
48 this.stop();
49 handlers?.onClose({ wasClean: true });
50 }
51
52 stop() {
53 clearTimeout(this.openTimer);
54 clearInterval(this.interval);
55 this.handlers = null;
56 }
57}
58
59// Symulacja Server-Sent Events: serwer sam wysyła powiadomienia misji, klient tylko słucha
60class MockSSEServer {
61 subscribe(onEvent) {
62 const events = [
63 { type: 'mission_update', data: 'Misja Nebula: etap 3 ukończony' },
64 { type: 'alert', data: 'Wykryto anomalię grawitacyjną w sektorze 7' },
65 { type: 'crew_message', data: 'Dr. Stellar: analiza próbek zakończona' },
66 { type: 'system', data: 'Dostępna aktualizacja oprogramowania nawigacyjnego' },
67 { type: 'alert', data: 'Zbliżamy się do pola asteroid - zmiana kursu' },
68 { type: 'mission_update', data: 'Nowa misja przydzielona: eksploracja Io' },
69 ];
70
71 let index = 0;
72 const interval = setInterval(() => {
73 const event = events[index % events.length];
74 index++;
75 onEvent({ ...event, id: index, timestamp: new Date().toLocaleTimeString('pl') });
76 }, 2500);
77
78 return () => clearInterval(interval);
79 }
80}
81
82const MAX_RETRIES = 3;
83const RETRY_DELAY = 2000;
84
85// Hook do obsługi WebSocket: po zerwaniu łącza próbuje połączyć się ponownie
86// co 2 sekundy, najwyżej 3 razy; zamknięcie przez użytkownika nie wznawia połączenia
87function useWebSocket() {
88 const [status, setStatus] = useState('DISCONNECTED');
89 const [messages, setMessages] = useState([]);
90 const [attempt, setAttempt] = useState(0);
91 const serverRef = useRef(null);
92 const retryRef = useRef({ count: 0, timer: null });
93
94 const connect = useCallback(function openConnection() {
95 const server = new MockWebSocketServer();
96 serverRef.current = server;
97 setStatus('CONNECTING');
98 server.connect({
99 onOpen: () => {
100 retryRef.current.count = 0;
101 setAttempt(0);
102 setStatus('CONNECTED');
103 },
104 onMessage: (message) => setMessages((prev) => [message, ...prev].slice(0, 20)),
105 onClose: ({ wasClean }) => {
106 serverRef.current = null;
107 if (wasClean || retryRef.current.count >= MAX_RETRIES) {
108 setStatus('DISCONNECTED');
109 return;
110 }
111 // Nieczyste zamknięcie: kolejna próba po RETRY_DELAY
112 retryRef.current.count += 1;
113 setAttempt(retryRef.current.count);
114 setStatus('RECONNECTING');
115 retryRef.current.timer = setTimeout(openConnection, RETRY_DELAY);
116 },
117 });
118 }, []);
119
120 const disconnect = useCallback(() => {
121 clearTimeout(retryRef.current.timer);
122 retryRef.current.count = 0;
123 setAttempt(0);
124 if (serverRef.current) serverRef.current.close();
125 else setStatus('DISCONNECTED');
126 }, []);
127
128 const simulateDrop = useCallback(() => {
129 serverRef.current?.drop();
130 }, []);
131
132 const sendCommand = useCallback((command) => {
133 const message = { type: 'COMMAND', command, timestamp: Date.now() };
134 setMessages((prev) => [message, ...prev].slice(0, 20));
135 serverRef.current?.send(message);
136 }, []);
137
138 // Odmontowanie: zatrzymaj serwer i zaplanowaną próbę połączenia
139 useEffect(() => {
140 const retry = retryRef.current;
141 return () => {
142 clearTimeout(retry.timer);
143 serverRef.current?.stop();
144 };
145 }, []);
146
147 return { status, attempt, messages, connect, disconnect, simulateDrop, sendCommand };
148}
149
150const STATUS_LABELS = {
151 CONNECTING: 'Łączenie...',
152 CONNECTED: 'Połączono',
153 RECONNECTING: 'Ponowne łączenie',
154 DISCONNECTED: 'Rozłączono',
155};
156const STATUS_COLORS = { CONNECTING: '#ffa500', CONNECTED: '#00ff88', RECONNECTING: '#ffa500', DISCONNECTED: '#ff6b6b' };
157
158function TelemetryPanel() {
159 const { status, attempt, messages, connect, disconnect, simulateDrop, sendCommand } = useWebSocket();
160 const latest = messages.find((message) => message.type === 'TELEMETRY')?.data;
161 const lastAck = messages.find((message) => message.type === 'ACK');
162 const connected = status === 'CONNECTED';
163 const idle = status === 'DISCONNECTED';
164
165 const metrics = latest ? [
166 { label: 'Prędkość', value: `${latest.speed} km/h` },
167 { label: 'Wysokość', value: `${latest.altitude} km` },
168 { label: 'Paliwo', value: `${latest.fuel}%` },
169 { label: 'Temperatura', value: `${latest.temperature} °C` },
170 { label: 'Tlen', value: `${latest.oxygen}%` },
171 ] : [];
172
173 return (
174 <div style={styles.panel}>
175 <div style={styles.panelHeader}>
176 <h3 style={styles.heading}>WebSocket - telemetria</h3>
177 <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
178 <span style={{ ...styles.dot, background: STATUS_COLORS[status] }} />
179 <span style={{ color: '#8892b0', fontSize: '12px' }}>
180 {STATUS_LABELS[status]}{status === 'RECONNECTING' ? ` (${attempt}/${MAX_RETRIES})` : ''}
181 </span>
182 </div>
183 </div>
184 <div style={styles.buttons}>
185 <button onClick={connect} disabled={!idle} style={{ ...styles.btn, opacity: idle ? 1 : 0.4 }}>Połącz</button>
186 <button onClick={disconnect} disabled={idle} style={{ ...styles.btnDanger, opacity: idle ? 0.4 : 1 }}>Rozłącz</button>
187 <button onClick={simulateDrop} disabled={!connected} style={{ ...styles.btnDanger, opacity: connected ? 1 : 0.4 }}>Zerwij łącze</button>
188 <button onClick={() => sendCommand('PING')} disabled={!connected} style={{ ...styles.btn, opacity: connected ? 1 : 0.4 }}>Wyślij ping</button>
189 </div>
190 {latest && (
191 <div style={styles.metrics}>
192 {metrics.map((metric) => (
193 <div key={metric.label} style={styles.metricCard}>
194 <div style={{ color: '#8892b0', fontSize: '11px' }}>{metric.label}</div>
195 <div style={{ color: '#00d4ff', fontSize: '17px', fontWeight: 'bold' }}>{metric.value}</div>
196 </div>
197 ))}
198 </div>
199 )}
200 {lastAck && <p style={{ color: '#00ff88', fontSize: '12px', margin: '12px 0 0' }}>Serwer potwierdził komendę {lastAck.command} o {new Date(lastAck.timestamp).toLocaleTimeString('pl')}</p>}
201 {!latest && idle && <p style={styles.empty}>Kliknij "Połącz", aby uruchomić strumień danych.</p>}
202 </div>
203 );
204}
205
206const EVENT_LABELS = { mission_update: 'misja', alert: 'alarm', crew_message: 'załoga', system: 'system' };
207const EVENT_COLORS = { mission_update: '#00ff88', alert: '#ff6b6b', crew_message: '#ffa500', system: '#00d4ff' };
208
209function SSENotificationsPanel() {
210 const [events, setEvents] = useState([]);
211 const [listening, setListening] = useState(false);
212 const unsubscribeRef = useRef(null);
213
214 const startListening = () => {
215 unsubscribeRef.current = new MockSSEServer().subscribe((event) => {
216 setEvents((prev) => [event, ...prev].slice(0, 15));
217 });
218 setListening(true);
219 };
220
221 const stopListening = () => {
222 unsubscribeRef.current?.();
223 unsubscribeRef.current = null;
224 setListening(false);
225 };
226
227 // Przy odmontowaniu zamknij subskrypcję
228 useEffect(() => () => unsubscribeRef.current?.(), []);
229
230 return (
231 <div style={styles.panel}>
232 <div style={styles.panelHeader}>
233 <h3 style={styles.heading}>SSE - powiadomienia</h3>
234 <button onClick={listening ? stopListening : startListening} style={listening ? styles.btnDanger : styles.btn}>
235 {listening ? 'Zatrzymaj' : 'Subskrybuj'}
236 </button>
237 </div>
238 <div style={{ maxHeight: '300px', overflowY: 'auto' }}>
239 {events.length === 0 && <p style={styles.empty}>Brak powiadomień - kliknij "Subskrybuj".</p>}
240 {events.map((event) => (
241 <div key={event.id} style={{ ...styles.event, borderLeftColor: EVENT_COLORS[event.type] }}>
242 <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
243 <span style={{ color: EVENT_COLORS[event.type], fontSize: '11px', textTransform: 'uppercase', fontWeight: 'bold' }}>{EVENT_LABELS[event.type]}</span>
244 <span style={{ color: '#8892b0', fontSize: '11px' }}>{event.timestamp}</span>
245 </div>
246 <div style={{ color: '#e0e1dd', fontSize: '13px' }}>{event.data}</div>
247 </div>
248 ))}
249 </div>
250 </div>
251 );
252}
253
254function App() {
255 return (
256 <div style={styles.container}>
257 <h1 style={styles.title}>Centrum łączności w czasie rzeczywistym</h1>
258 <p style={styles.subtitle}>WebSockets i Server-Sent Events w React</p>
259 <div style={styles.grid}>
260 <TelemetryPanel />
261 <SSENotificationsPanel />
262 </div>
263 <p style={styles.note}>WebSocket przesyła dane w obie strony: serwer wysyła telemetrię, a klient komendy, jak ping. "Zerwij łącze" pokazuje ponowne łączenie. SSE płynie tylko od serwera do klienta, więc panel powiadomień niczego nie wysyła.</p>
264 </div>
265 );
266}
267
268const styles = {
269 container: { minHeight: '100vh', background: 'linear-gradient(135deg, #0d1b2a 0%, #1a1a3e 100%)', padding: '24px 16px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', boxSizing: 'border-box' },
270 title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 4px', fontSize: '24px' },
271 subtitle: { textAlign: 'center', color: '#8892b0', marginBottom: '20px' },
272 grid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '16px' },
273 panel: { background: 'rgba(0,0,0,0.3)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(0,212,255,0.2)' },
274 panelHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' },
275 heading: { color: '#00d4ff', margin: 0, fontSize: '17px' },
276 dot: { width: '10px', height: '10px', borderRadius: '50%', display: 'inline-block' },
277 buttons: { display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '12px' },
278 metrics: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))', gap: '8px' },
279 metricCard: { padding: '10px', background: 'rgba(0,212,255,0.05)', borderRadius: '8px', textAlign: 'center', border: '1px solid rgba(0,212,255,0.15)' },
280 event: { padding: '8px 12px', borderLeft: '3px solid', background: 'rgba(0,0,0,0.2)', marginBottom: '6px', borderRadius: '0 6px 6px 0' },
281 empty: { color: '#8892b0', textAlign: 'center', fontSize: '13px' },
282 btn: { padding: '8px 12px', background: 'rgba(0,212,255,0.15)', border: '1px solid #00d4ff', borderRadius: '6px', color: '#00d4ff', cursor: 'pointer', fontSize: '13px' },
283 btnDanger: { padding: '8px 12px', background: 'rgba(255,107,107,0.15)', border: '1px solid #ff6b6b', borderRadius: '6px', color: '#ff6b6b', cursor: 'pointer', fontSize: '13px' },
284 note: { maxWidth: '760px', margin: '16px auto 0', color: '#8892b0', fontSize: '13px', lineHeight: 1.5 },
285};
286
287export default App;Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Kiedy WebSocket jest lepszym wyborem niż REST API w aplikacji React?
2. Czym Server-Sent Events (SSE) różni się od WebSocket?
Zadania praktyczne w grze
- Edytor kodu
Dokończ hook useMissionSocket, który obsługuje kanał łączności ze stacją. Gniazdo tworzy createSocket(url) - w podglądzie to symulator stacji o takim samym interfejsie jak WebSocket. ___BLANK1___: w onmessage zamień tekst z event.data na obiekt wiadomości przez JSON.parse. ___BLANK2___: funkcja cleanup efektu zamyka gniazdo metodą close(), gdy komponent znika (także przy podwójnym uruchomieniu efektu w StrictMode). ___BLANK3___: send wysyła obiekt { text } zamieniony na tekst przez JSON.stringify. Po kilku sekundach w podglądzie pojawi się telemetria, a każda Twoja wiadomość wróci jako echo.