11.4 ווב סוקטים וזמן אמת הרצאה
ווב-סוקטים וזמן אמת - WebSockets and Real-time¶
תקשורת בזמן אמת היא חלק חיוני מאפליקציות מודרניות - צ'אט, התראות חיות, עדכוני מחירים, שיתוף פעולה בזמן אמת ועוד. בשיעור זה נלמד על הפרוטוקולים והכלים שמאפשרים תקשורת דו-כיוונית בין הדפדפן לשרת.
HTTP מול WebSocket¶
המודל הרגיל - HTTP Request/Response¶
לקוח שרת
| --- GET /messages ------> |
| <--- 200 OK + data ----- |
| |
| (5 שניות מאוחר יותר) |
| --- GET /messages ------> |
| <--- 200 OK + data ----- |
- בקשה ותגובה - הלקוח שואל, השרת עונה
- כל בקשה פותחת חיבור חדש
- אם רוצים עדכונים - צריך לשאול שוב ושוב (polling)
- לא יעיל לתקשורת בזמן אמת
המודל של WebSocket¶
לקוח שרת
| --- HTTP Upgrade ------> |
| <--- 101 Switching --- |
| |
| <=== חיבור פתוח ====> |
| --- הודעה 1 -----------> |
| <--- הודעה 2 ---------- |
| <--- הודעה 3 ---------- |
| --- הודעה 4 -----------> |
| <=== חיבור פתוח ====> |
- חיבור אחד שנשאר פתוח
- תקשורת דו-כיוונית - שני הצדדים יכולים לשלוח הודעות
- יעיל - אין overhead של פתיחת חיבורים חדשים
- מעודכן מיד - השרת יכול לשלוח הודעות ללא שהלקוח מבקש
WebSocket API בדפדפן¶
שימוש בסיסי¶
// יצירת חיבור WebSocket
const ws = new WebSocket('wss://api.example.com/ws');
// אירוע פתיחת חיבור
ws.addEventListener('open', () => {
console.log('חיבור נפתח');
ws.send('שלום שרת!');
});
// אירוע קבלת הודעה
ws.addEventListener('message', (event) => {
console.log('התקבלה הודעה:', event.data);
// אם ההודעה היא JSON
const data = JSON.parse(event.data);
console.log(data);
});
// אירוע סגירת חיבור
ws.addEventListener('close', (event) => {
console.log('החיבור נסגר:', event.code, event.reason);
});
// אירוע שגיאה
ws.addEventListener('error', (event) => {
console.error('שגיאת WebSocket:', event);
});
// שליחת הודעה
ws.send(JSON.stringify({ type: 'chat', message: 'שלום!' }));
// סגירת החיבור
ws.close();
Hook לריאקט - useWebSocket¶
import { useEffect, useRef, useState, useCallback } from 'react';
interface UseWebSocketOptions {
url: string;
onMessage?: (data: unknown) => void;
onOpen?: () => void;
onClose?: () => void;
onError?: (error: Event) => void;
reconnect?: boolean;
reconnectInterval?: number;
maxRetries?: number;
}
type ReadyState = 'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED';
function useWebSocket({
url,
onMessage,
onOpen,
onClose,
onError,
reconnect = true,
reconnectInterval = 3000,
maxRetries = 5,
}: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const retriesRef = useRef(0);
const [readyState, setReadyState] = useState<ReadyState>('CONNECTING');
const connect = useCallback(() => {
const ws = new WebSocket(url);
ws.addEventListener('open', () => {
setReadyState('OPEN');
retriesRef.current = 0;
onOpen?.();
});
ws.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data);
onMessage?.(data);
} catch {
onMessage?.(event.data);
}
});
ws.addEventListener('close', () => {
setReadyState('CLOSED');
onClose?.();
// ניסיון התחברות מחדש
if (reconnect && retriesRef.current < maxRetries) {
retriesRef.current += 1;
setTimeout(connect, reconnectInterval);
}
});
ws.addEventListener('error', (event) => {
onError?.(event);
});
wsRef.current = ws;
}, [url, onMessage, onOpen, onClose, onError, reconnect, reconnectInterval, maxRetries]);
useEffect(() => {
connect();
return () => {
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
typeof data === 'string' ? data : JSON.stringify(data)
);
}
}, []);
return { send, readyState };
}
export default useWebSocket;
Socket.io - לקוח¶
Socket.io היא ספרייה שמפשטת עבודה עם WebSockets ומוסיפה יכולות כמו חדרים, reconnection אוטומטי, ו-fallback ל-polling.
שימוש בסיסי¶
import { io, type Socket } from 'socket.io-client';
// יצירת חיבור
const socket: Socket = io('https://api.example.com', {
autoConnect: false,
auth: {
token: 'user-auth-token',
},
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
// חיבור ידני
socket.connect();
// שליחת אירוע
socket.emit('chat:message', { text: 'שלום!', room: 'general' });
// האזנה לאירוע
socket.on('chat:message', (data) => {
console.log('הודעה חדשה:', data);
});
// שליחה עם acknowledgment
socket.emit('chat:message', { text: 'שלום!' }, (response: { ok: boolean }) => {
console.log('השרת אישר:', response);
});
// ניתוק
socket.disconnect();
Hook לריאקט עם Socket.io¶
// hooks/useSocket.ts
import { useEffect, useRef, useCallback, useState } from 'react';
import { io, type Socket } from 'socket.io-client';
interface UseSocketOptions {
url: string;
auth?: Record<string, string>;
}
function useSocket({ url, auth }: UseSocketOptions) {
const socketRef = useRef<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const socket = io(url, {
auth,
autoConnect: true,
reconnection: true,
reconnectionAttempts: 5,
});
socket.on('connect', () => setIsConnected(true));
socket.on('disconnect', () => setIsConnected(false));
socketRef.current = socket;
return () => {
socket.disconnect();
};
}, [url, auth]);
const emit = useCallback((event: string, data?: unknown) => {
socketRef.current?.emit(event, data);
}, []);
const on = useCallback((event: string, handler: (...args: unknown[]) => void) => {
socketRef.current?.on(event, handler);
return () => {
socketRef.current?.off(event, handler);
};
}, []);
return { emit, on, isConnected, socket: socketRef.current };
}
export default useSocket;
בניית קומפוננטת צ'אט בזמן אמת¶
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import useSocket from '@/hooks/useSocket';
interface ChatMessage {
id: string;
author: string;
text: string;
timestamp: Date;
}
interface ChatProps {
room: string;
username: string;
}
function Chat({ room, username }: ChatProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout>();
const { emit, on, isConnected } = useSocket({
url: process.env.NEXT_PUBLIC_SOCKET_URL || 'http://localhost:3001',
auth: { username },
});
// הצטרפות לחדר
useEffect(() => {
emit('room:join', { room });
return () => {
emit('room:leave', { room });
};
}, [room, emit]);
// האזנה להודעות
useEffect(() => {
const unsubMessage = on('chat:message', (data: unknown) => {
const message = data as ChatMessage;
setMessages((prev) => [...prev, { ...message, timestamp: new Date(message.timestamp) }]);
});
const unsubHistory = on('chat:history', (data: unknown) => {
const history = data as ChatMessage[];
setMessages(history.map((m) => ({ ...m, timestamp: new Date(m.timestamp) })));
});
const unsubTyping = on('chat:typing', (data: unknown) => {
const { user, isTyping } = data as { user: string; isTyping: boolean };
setTypingUsers((prev) =>
isTyping
? [...prev.filter((u) => u !== user), user]
: prev.filter((u) => u !== user)
);
});
return () => {
unsubMessage();
unsubHistory();
unsubTyping();
};
}, [on]);
// גלילה אוטומטית למטה
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// שליחת הודעה
const sendMessage = useCallback(() => {
if (!inputValue.trim()) return;
emit('chat:message', {
room,
text: inputValue.trim(),
});
setInputValue('');
}, [inputValue, room, emit]);
// אינדיקציית הקלדה
const handleInputChange = useCallback(
(value: string) => {
setInputValue(value);
emit('chat:typing', { room, isTyping: true });
// ביטול timeout קודם
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// הפסקת אינדיקציה אחרי 2 שניות ללא הקלדה
typingTimeoutRef.current = setTimeout(() => {
emit('chat:typing', { room, isTyping: false });
}, 2000);
},
[room, emit]
);
return (
<div className="chat-container">
{/* סטטוס חיבור */}
<div className={`status ${isConnected ? 'connected' : 'disconnected'}`}>
{isConnected ? 'מחובר' : 'מנותק...'}
</div>
{/* הודעות */}
<div className="messages" role="log" aria-live="polite">
{messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.author === username ? 'own' : 'other'}`}
>
<strong>{msg.author}</strong>
<p>{msg.text}</p>
<time>{new Date(msg.timestamp).toLocaleTimeString()}</time>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* אינדיקציית הקלדה */}
{typingUsers.length > 0 && (
<p className="typing-indicator">
{typingUsers.join(', ')} מקלידים...
</p>
)}
{/* שדה קלט */}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage();
}}
className="input-area"
>
<input
type="text"
value={inputValue}
onChange={(e) => handleInputChange(e.target.value)}
placeholder="הקלד הודעה..."
disabled={!isConnected}
/>
<button type="submit" disabled={!isConnected || !inputValue.trim()}>
שלח
</button>
</form>
</div>
);
}
export default Chat;
אירועים שנשלחים מהשרת - Server-Sent Events (SSE)¶
SSE הוא פרוטוקול חד-כיווני - השרת שולח אירועים ללקוח. פשוט יותר מ-WebSocket אך מוגבל לכיוון אחד.
מתי להשתמש ב-SSE במקום WebSocket?¶
- עדכוני חדשות או מחירים (הלקוח רק מקבל)
- התראות בזמן אמת
- עדכוני התקדמות (progress)
- כשלא צריך תקשורת דו-כיוונית
שימוש ב-SSE בדפדפן¶
// EventSource API
const eventSource = new EventSource('/api/events');
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('אירוע:', data);
});
eventSource.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
console.log('התראה:', data);
});
eventSource.addEventListener('error', () => {
console.log('שגיאה - ינסה להתחבר מחדש אוטומטית');
});
// סגירה
eventSource.close();
SSE ב-Next.js API Route¶
// app/api/events/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// שליחת אירוע כל 5 שניות
const interval = setInterval(() => {
const data = JSON.stringify({
time: new Date().toISOString(),
message: 'עדכון מהשרת',
});
controller.enqueue(
encoder.encode(`data: ${data}\n\n`)
);
}, 5000);
// שליחת אירוע מותאם אישית
const notification = JSON.stringify({ title: 'שלום!' });
controller.enqueue(
encoder.encode(`event: notification\ndata: ${notification}\n\n`)
);
// ניקוי בסגירה
setTimeout(() => {
clearInterval(interval);
controller.close();
}, 60000); // סגירה אחרי דקה
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
Hook לשימוש ב-SSE¶
import { useEffect, useRef, useState, useCallback } from 'react';
function useSSE<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const sourceRef = useRef<EventSource | null>(null);
useEffect(() => {
const source = new EventSource(url);
source.addEventListener('open', () => {
setIsConnected(true);
setError(null);
});
source.addEventListener('message', (event) => {
try {
const parsed = JSON.parse(event.data) as T;
setData(parsed);
} catch {
setError('Failed to parse event data');
}
});
source.addEventListener('error', () => {
setIsConnected(false);
setError('Connection lost');
});
sourceRef.current = source;
return () => {
source.close();
};
}, [url]);
const close = useCallback(() => {
sourceRef.current?.close();
setIsConnected(false);
}, []);
return { data, error, isConnected, close };
}
// שימוש
function StockTicker() {
const { data, isConnected } = useSSE<{ symbol: string; price: number }>(
'/api/stocks'
);
return (
<div>
<p>{isConnected ? 'מחובר' : 'מנותק'}</p>
{data && (
<p>
{data.symbol}: ${data.price}
</p>
)}
</div>
);
}
דפוסי זמן אמת - Real-time Patterns¶
חדרים - Rooms¶
// הצטרפות ועזיבת חדרים
function ChatRooms() {
const { emit, on } = useSocket({ url: SOCKET_URL });
const [currentRoom, setCurrentRoom] = useState('general');
const joinRoom = (room: string) => {
emit('room:leave', { room: currentRoom });
emit('room:join', { room });
setCurrentRoom(room);
};
return (
<div>
<button onClick={() => joinRoom('general')}>כללי</button>
<button onClick={() => joinRoom('support')}>תמיכה</button>
<button onClick={() => joinRoom('random')}>אקראי</button>
</div>
);
}
נוכחות - Presence¶
// מעקב אחרי משתמשים מחוברים
function OnlineUsers() {
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
const { on } = useSocket({ url: SOCKET_URL });
useEffect(() => {
const unsub = on('presence:update', (data: unknown) => {
const { users } = data as { users: string[] };
setOnlineUsers(users);
});
return unsub;
}, [on]);
return (
<div>
<h3>משתמשים מחוברים ({onlineUsers.length})</h3>
<ul>
{onlineUsers.map((user) => (
<li key={user}>
<span className="online-dot" /> {user}
</li>
))}
</ul>
</div>
);
}
שידור - Broadcasting¶
// עדכון בזמן אמת לכל המשתמשים
function LiveNotifications() {
const [notifications, setNotifications] = useState<string[]>([]);
const { on } = useSocket({ url: SOCKET_URL });
useEffect(() => {
const unsub = on('broadcast:notification', (data: unknown) => {
const { message } = data as { message: string };
setNotifications((prev) => [message, ...prev].slice(0, 50));
});
return unsub;
}, [on]);
return (
<div aria-live="polite">
{notifications.map((notif, i) => (
<p key={i}>{notif}</p>
))}
</div>
);
}
אסטרטגיות חיבור מחדש וטיפול בשגיאות¶
import { useEffect, useRef, useState, useCallback } from 'react';
interface ReconnectOptions {
maxRetries: number;
baseDelay: number;
maxDelay: number;
}
function useReconnectingWebSocket(
url: string,
options: ReconnectOptions = { maxRetries: 10, baseDelay: 1000, maxDelay: 30000 }
) {
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
const [retryCount, setRetryCount] = useState(0);
const connect = useCallback(() => {
setStatus('connecting');
const ws = new WebSocket(url);
ws.addEventListener('open', () => {
setStatus('connected');
setRetryCount(0);
});
ws.addEventListener('close', (event) => {
setStatus('disconnected');
// אל תנסה מחדש אם נסגר בכוונה
if (event.code === 1000) return;
if (retryCount < options.maxRetries) {
// Exponential backoff עם jitter
const delay = Math.min(
options.baseDelay * Math.pow(2, retryCount) + Math.random() * 1000,
options.maxDelay
);
setTimeout(() => {
setRetryCount((prev) => prev + 1);
connect();
}, delay);
}
});
ws.addEventListener('error', () => {
// שגיאה - ה-close event ידאג ל-reconnect
});
wsRef.current = ws;
}, [url, retryCount, options]);
useEffect(() => {
connect();
return () => {
wsRef.current?.close(1000, 'Component unmounted');
};
}, []);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
return { send, status, retryCount };
}
// קומפוננטת סטטוס חיבור
function ConnectionStatus({ status, retryCount }: { status: string; retryCount: number }) {
return (
<div className={`connection-status ${status}`} role="status" aria-live="polite">
{status === 'connected' && 'מחובר'}
{status === 'connecting' && `מתחבר... (ניסיון ${retryCount + 1})`}
{status === 'disconnected' && 'מנותק'}
</div>
);
}
השוואה - WebSocket מול SSE מול Polling¶
| תכונה | WebSocket | SSE | Polling |
|---|---|---|---|
| כיוון | דו-כיווני | שרת ללקוח | לקוח לשרת |
| פרוטוקול | ws:// / wss:// | HTTP | HTTP |
| חיבור מחדש | ידני | אוטומטי | לא רלוונטי |
| תמיכה בדפדפנים | מעולה | מעולה | מעולה |
| מורכבות | בינונית | נמוכה | נמוכה |
| שימוש | צ'אט, משחקים | התראות, עדכונים | דשבורדים פשוטים |
סיכום¶
בשיעור זה למדנו על:
- HTTP מול WebSocket - ההבדל בין request/response לחיבור מתמשך
- WebSocket API - יצירת חיבורים, שליחה וקבלה של הודעות
- Socket.io - ספריית client עם חדרים, אירועים ו-reconnection
- צ'אט בזמן אמת - בניית קומפוננטת צ'אט מלאה עם הקלדה ונוכחות
- SSE - אירועים חד-כיווניים מהשרת, פשוט ויעיל
- דפוסי זמן אמת - חדרים, נוכחות ושידור
- חיבור מחדש - Exponential backoff וטיפול בשגיאות
הבחירה בין WebSocket, SSE ו-Polling תלויה בצרכי האפליקציה - אם צריך תקשורת דו-כיוונית, WebSocket הוא הפתרון. לעדכונים חד-כיווניים, SSE פשוט ויעיל יותר.