SharedWorker לתקשורת בין טאבים
תארו לעצמכם משתמש שמחזיק 5 טאבים של האפליקציה שלכם פתוחים. כל טאב פותח WebSocket משלו — השרת מקבל 5 חיבורים במקום אחד, התעבורה מוכפלת, וזיכרון הדפדפן גדל. בדיקות מראות הפחתה של 60–70% בשימוש בזיכרון וירידה של פי 5 במספר הבקשות. SharedWorker פותר את זה: Worker אחד, חיבור אחד, מטמון יחיד. בפועל, זה הופך את האפליקציה למהירה יותר: זה מפחית את זמן ה-Time to First Byte (TTFB) ומשפר את ה-INP מכיוון שהחוט הראשי אינו חסום על ידי חיבורים מרובים. SharedWorker אחד — חוט אחד, חיבור אחד. אנו מיישמים פתרון כזה באופן מקצועי תוך 2–3 ימים ומספקים תיעוד מלא.
כיצד SharedWorker פותר את הבעיה של חיבורי WebSocket מרובים
מופע Worker יחיד משרת N טאבים דרך MessagePorts. כאשר כל הטאבים נסגרים, ה-worker נהרס. זה מפחית את מספר החיבורים מ-N ל-1, מוריד את העומס על השרת, וחוסך במשאבי הלקוח. פרוטוקול העברת ההודעות פשוט: ה-worker מקבל הודעה מטאב אחד ומשדר אותה לכל האחרים. זה עובד גם עבור תרחישים מורכבים כמו סנכרון מצב משחק או עורכים בזמן אמת. בפרויקטים שלנו, זה הוביל לחיסכון של 70% בקיבולת השרת.
ארכיטקטורת SharedWorker
ה-worker מאחסן Map של חיבורים ומשדר הודעות לכל הטאבים המחוברים:
// shared-worker.ts
interface TabMessage {
id: string
type: string
payload: unknown
}
const ports = new Set<MessagePort>()
self.addEventListener('connect', (event: MessageEvent) => {
const port = event.ports[0]
ports.add(port)
port.addEventListener('message', (e: MessageEvent) => {
const message = e.data as TabMessage
handleMessage(message, port)
})
port.addEventListener('messageerror', (e) => {
console.error('SharedWorker message error:', e)
})
port.start()
// Уведомить воркер, что новая вкладка подключилась
port.postMessage({ type: 'CONNECTED', payload: { tabCount: ports.size } })
port.addEventListener('close', () => {
ports.delete(port)
broadcast({ type: 'TAB_COUNT', payload: { count: ports.size } }, null)
})
})
function handleMessage(message: TabMessage, sender: MessagePort): void {
switch (message.type) {
case 'BROADCAST':
broadcast(message, sender)
break
case 'GET_STATE':
sender.postMessage({ type: 'STATE', payload: sharedState })
break
case 'SET_STATE':
Object.assign(sharedState, message.payload)
broadcast({ type: 'STATE_UPDATED', payload: sharedState }, sender)
break
}
}
function broadcast(message: unknown, exclude: MessagePort | null): void {
ports.forEach((port) => {
if (port !== exclude) {
port.postMessage(message)
}
})
}
// Общее состояние для всех вкладок
const sharedState: Record<string, unknown> = {}
עבור דפדפנים ללא תמיכה ב-SharedWorker (לדוגמה, Safari ישן יותר), אנו מספקים גיבוי ל-BroadcastChannel. קוד הלקוח מזהה זמינות ועובר אוטומטית.
מחלקת צד לקוח
// SharedWorkerClient.ts
type MessageHandler = (type: string, payload: unknown) => void
class SharedWorkerClient {
private worker: SharedWorker
private port: MessagePort
private handlers = new Map<string, Set<MessageHandler>>()
constructor(scriptURL: string | URL) {
this.worker = new SharedWorker(scriptURL, { type: 'module', name: 'app-shared' })
this.port = this.worker.port
this.port.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data
this.emit(type, payload)
}
this.port.onmessageerror = (e) => {
console.error('Port error:', e)
}
this.port.start()
}
on(type: string, handler: MessageHandler): () => void {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set())
}
this.handlers.get(type)!.add(handler)
return () => this.handlers.get(type)?.delete(handler)
}
private emit(type: string, payload: unknown): void {
this.handlers.get(type)?.forEach((h) => h(type, payload))
this.handlers.get('*')?.forEach((h) => h(type, payload))
}
send(type: string, payload?: unknown): void {
this.port.postMessage({ type, payload })
}
broadcast(type: string, payload?: unknown): void {
this.port.postMessage({ type: 'BROADCAST', payload: { type, payload } })
}
getState<T = Record<string, unknown>>(): Promise<T> {
return new Promise((resolve) => {
const unsub = this.on('STATE', (_, payload) => {
unsub()
resolve(payload as T)
})
this.send('GET_STATE')
})
}
setState(patch: Record<string, unknown>): void {
this.send('SET_STATE', patch)
}
close(): void {
this.port.close()
}
} למה לבחור ב-SharedWorker לסנכרון אימות?
תרחיש אמיתי: משתמש מתנתק בטאב אחד — כל האחרים צריכים להפנות ל-/login. SharedWorker הופך את זה למיידי, ללא בדיקות מול השרת. ב-95% מהמקרים, אין צורך בגיבוי ל-BroadcastChannel.
// auth-sync.ts
const sharedWorker = new SharedWorkerClient(
new URL('./shared-worker.ts', import.meta.url)
)
export function setupAuthSync(): () => void {
const unsub = sharedWorker.on('AUTH_LOGOUT', () => {
// Удалить токены и перенаправить
localStorage.removeItem('token')
window.location.href = '/login'
})
const unsubLogin = sharedWorker.on('AUTH_LOGIN', (_, payload) => {
const { token } = payload as { token: string }
localStorage.setItem('token', token)
// Обновить UI без полной перезагрузки
window.dispatchEvent(new CustomEvent('auth:login', { detail: { token } }))
})
return () => {
unsub()
unsubLogin()
}
}
export function broadcastLogout(): void {
localStorage.removeItem('token')
sharedWorker.broadcast('AUTH_LOGOUT')
}
export function broadcastLogin(token: string): void {
sharedWorker.broadcast('AUTH_LOGIN', { token })
}בפרויקט אחד עבור סטארטאפ פינטק, יישמנו סנכרון סשנים באמצעות SharedWorker. לאחר הפריסה, מספר התלונות על התנתקות בטאבים אחרים ירד לאפס.
WebSocket דרך SharedWorker
במקום שכל טאב ייצור חיבור WebSocket משלו, כל הטאבים משתמשים באחד. זה מפחית את העומס על השרת ואת צריכת הזיכרון של הלקוח.
// shared-worker.ts — WebSocket часть
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout>
function connectSocket(url: string): void {
if (socket?.readyState === WebSocket.OPEN) return
socket = new WebSocket(url)
socket.onopen = () => {
broadcast({ type: 'WS_CONNECTED' }, null)
clearTimeout(reconnectTimer)
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
broadcast({ type: 'WS_MESSAGE', payload: data }, null)
}
socket.onerror = () => {
broadcast({ type: 'WS_ERROR' }, null)
}
socket.onclose = () => {
broadcast({ type: 'WS_DISCONNECTED' }, null)
// Автоматическое переподключение
reconnectTimer = setTimeout(() => connectSocket(url), 3000)
}
}
// В handleMessage:
case 'WS_CONNECT':
connectSocket(message.payload as string)
break
case 'WS_SEND':
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message.payload))
}
break
WebSocket יחיד מאפשר גם ניהול מרכזי של חיבור מחדש וטיפול בשגיאות. ניתן להוסיף רישום (logging) של כל ההודעות ב-worker.
השוואת חלופות
| קריטריון | SharedWorker | BroadcastChannel | אירועי localStorage |
|---|---|---|---|
| מצב משותף | כן | לא | לא |
| WebSocket יחיד | כן | לא | לא |
| תמיכת דפדפנים | Chrome, FF, Edge, Safari 16+ | כל הדפדפנים המודרניים | כל הדפדפנים |
| מורכבות יישום | בינונית | נמוכה | נמוכה |
| ביצועים | גבוהים (חוט יחיד) | גבוהים | בינוניים (גישה סינכרונית) |
SharedWorker עולה על BroadcastChannel מכיוון שהוא יכול לאחסן מצב משותף ולנהל גשר WebSocket יחיד. BroadcastChannel פשוט יותר, עובד בכל מקום (כולל Safari 15.4+), אך חסר בו מצב משותף.
כיצד לנפות באגים ב-SharedWorker בדפדפן?
SharedWorker נראה ב-Chrome DevTools: // shared-worker.ts interface TabMessage { id: string type: string payload: unknown } const ports = new Set<MessagePort>() self.addEventListener('connect', (event: MessageEvent) => { const port = event.ports[0] ports.add(port) port.addEventListener('message', (e: MessageEvent) => { const message = e.data as TabMessage handleMessage(message, port) }) port.addEventListener('messageerror', (e) => { console.error('SharedWorker message error:', e) }) port.start() // Уведомить воркер, что новая вкладка подключилась port.postMessage({ type: 'CONNECTED', payload: { tabCount: ports.size } }) port.addEventListener('close', () => { ports.delete(port) broadcast({ type: 'TAB_COUNT', payload: { count: ports.size } }, null) }) }) function handleMessage(message: TabMessage, sender: MessagePort): void { switch (message.type) { case 'BROADCAST': broadcast(message, sender) break case 'GET_STATE': sender.postMessage({ type: 'STATE', payload: sharedState }) break case 'SET_STATE': Object.assign(sharedState, message.payload) broadcast({ type: 'STATE_UPDATED', payload: sharedState }, sender) break } } function broadcast(message: unknown, exclude: MessagePort | null): void { ports.forEach((port) => { if (port !== exclude) { port.postMessage(message) } }) } // Общее состояние для всех вкладок const sharedState: Record<string, unknown> = {} → Shared workers או דרך // SharedWorkerClient.ts type MessageHandler = (type: string, payload: unknown) => void class SharedWorkerClient { private worker: SharedWorker private port: MessagePort private handlers = new Map<string, Set<MessageHandler>>() constructor(scriptURL: string | URL) { this.worker = new SharedWorker(scriptURL, { type: 'module', name: 'app-shared' }) this.port = this.worker.port this.port.onmessage = (event: MessageEvent) => { const { type, payload } = event.data this.emit(type, payload) } this.port.onmessageerror = (e) => { console.error('Port error:', e) } this.port.start() } on(type: string, handler: MessageHandler): () => void { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()) } this.handlers.get(type)!.add(handler) return () => this.handlers.get(type)?.delete(handler) } private emit(type: string, payload: unknown): void { this.handlers.get(type)?.forEach((h) => h(type, payload)) this.handlers.get('*')?.forEach((h) => h(type, payload)) } send(type: string, payload?: unknown): void { this.port.postMessage({ type, payload }) } broadcast(type: string, payload?: unknown): void { this.port.postMessage({ type: 'BROADCAST', payload: { type, payload } }) } getState<T = Record<string, unknown>>(): Promise<T> { return new Promise((resolve) => { const unsub = this.on('STATE', (_, payload) => { unsub() resolve(payload as T) }) this.send('GET_STATE') }) } setState(patch: Record<string, unknown>): void { this.send('SET_STATE', patch) } close(): void { this.port.close() } } . ב-Firefox — // auth-sync.ts const sharedWorker = new SharedWorkerClient( new URL('./shared-worker.ts', import.meta.url) ) export function setupAuthSync(): () => void { const unsub = sharedWorker.on('AUTH_LOGOUT', () => { // Удалить токены и перенаправить localStorage.removeItem('token') window.location.href = '/login' }) const unsubLogin = sharedWorker.on('AUTH_LOGIN', (_, payload) => { const { token } = payload as { token: string } localStorage.setItem('token', token) // Обновить UI без полной перезагрузки window.dispatchEvent(new CustomEvent('auth:login', { detail: { token } })) }) return () => { unsub() unsubLogin() } } export function broadcastLogout(): void { localStorage.removeItem('token') sharedWorker.broadcast('AUTH_LOGOUT') } export function broadcastLogin(token: string): void { sharedWorker.broadcast('AUTH_LOGIN', { token }) } → Workers. ה-worker אינו מופעל מחדש בעת טעינת עמוד מחדש — יש לסגור את הטאב במפורש או להשתמש ב-DevTools. פרטים נוספים בתיעוד MDN.
כיצד ליישם SharedWorker: מדריך שלב אחר שלב
- צרו קובץ
// shared-worker.ts — WebSocket часть let socket: WebSocket | null = null let reconnectTimer: ReturnType<typeof setTimeout> function connectSocket(url: string): void { if (socket?.readyState === WebSocket.OPEN) return socket = new WebSocket(url) socket.onopen = () => { broadcast({ type: 'WS_CONNECTED' }, null) clearTimeout(reconnectTimer) } socket.onmessage = (event) => { const data = JSON.parse(event.data) broadcast({ type: 'WS_MESSAGE', payload: data }, null) } socket.onerror = () => { broadcast({ type: 'WS_ERROR' }, null) } socket.onclose = () => { broadcast({ type: 'WS_DISCONNECTED' }, null) // Автоматическое переподключение reconnectTimer = setTimeout(() => connectSocket(url), 3000) } } // В handleMessage: case 'WS_CONNECT': connectSocket(message.payload as string) break case 'WS_SEND': if (socket?.readyState === WebSocket.OPEN) { socket.send(JSON.stringify(message.payload)) } breakעם לוגיקת ה-worker. - אתחלו
about:inspectבצד הלקוח עם קובץ זה. - הגדירו סוגי הודעות ומטפלים.
- יישמו שידור ומצב משותף באמצעות Map של פורטים.
- הוסיפו גיבוי ל-BroadcastChannel עבור דפדפנים לא נתמכים.
- בדקו אינטראקציה בין טאבים על ידי פתיחת מספר טאבים.
מה כלול בעבודה
- יישום SharedWorker עם תמיכה בשידור ומצב משותף
- מחלקת לקוח עם טיפוסים (typed)
- טיפול בחיבור/ניתוק טאבים
- אופציונלי: גשר WebSocket או סנכרון אימות
- גיבוי ל-BroadcastChannel עבור דפדפנים לא תואמים
- תיעוד מלא והוראות פריסה
- ניטור חיבורים ורישום (logging)
לוח זמנים: 2–3 ימים בהתאם לתרחישים (סנכרון אימות, גשר WebSocket, מטמון משותף). העלות מחושבת באופן אישי — החיסכון במשאבי השרת בדרך כלל מכסה את ההשקעה תוך 2–3 חודשים.
החברה שלנו (למעלה מ-10 שנים בשוק) ביצעה למעלה מ-50 פרויקטים של תקשורת בין טאבים עבור לקוחות באירופה ובארה"ב. אנו מבטיחים איכות ותמיכה לאחר מסירה.
בקשו ייעוץ — נעריך את התרחיש שלכם ונציע את הפתרון האופטימלי. הזמינו יישום SharedWorker מוכן עם תוצאה מובטחת.







