При работе с WebSocket-соединениями через библиотеку STOMP.js часто возникает проблема множественных подключений. Если пользователь открывает несколько вкладок одного сайта, каждая вкладка создает собственное STOMP-подключение к брокеру сообщений.
Подобное поведение приводит к ряду последствий:
Синхронизация между вкладками позволяет:
Для координации вкладок браузера обычно используются:
| Технология | Назначение |
|---|---|
| BroadcastChannel | Передача сообщений между вкладками |
| localStorage events | События изменения localStorage |
| SharedWorker | Общий воркер для всех вкладок |
| Service Worker | Центральная точка коммуникации |
| IndexedDB | Хранение общего состояния |
| Web Locks API | Блокировки и выбор лидера |
Наиболее популярным вариантом считается комбинация:
Типичная реализация выглядит так:
import { Client } from '@stomp/stompjs';
const client = new Client({
brokerURL: 'ws://localhost:15674/ws'
});
client.activate();
Если открыто пять вкладок:
При высокой нагрузке это становится критично.
Схема работы:
const channel = new BroadcastChannel('stomp-sync');
Все вкладки с одинаковым именем канала могут обмениваться сообщениями.
channel.postMessage({
type: 'CHAT_MESSAGE',
payload: {
text: 'Hello'
}
});
channel.onmess age = (event) => {
console.log(event.data);
};
Каждая вкладка получает уникальный идентификатор:
const TAB_ID = crypto.randomUUID();
Попытка занять роль лидера:
localStorage.setItem('stomp-leader', TAB_ID);
Проверка:
const currentLeader = localStorage.getItem('stomp-leader');
const isLeader = currentLeader === TAB_ID;
Но подобная реализация небезопасна из-за гонок.
Более надежный вариант использует heartbeat.
Лидер периодически обновляет timestamp:
setInterval(() => {
localStorage.setItem('stomp-heartbeat', Date.now());
}, 1000);
function isLeaderAlive() {
const heartbeat = Number(
localStorage.getItem('stomp-heartbeat')
);
return Date.now() - heartbeat < 3000;
}
function tryBecomeLeader() {
if (!isLeaderAlive()) {
localStorage.setItem('stomp-leader', TAB_ID);
}
}
Лидер создает клиента:
import { Client } from '@stomp/stompjs';
const client = new Client({
brokerURL: 'ws://localhost:15674/ws',
reconnectDelay: 5000
});
client.onConn ect = () => {
console.log('Connected');
};
client.activate();
client.subscribe('/topic/messages', (message) => {
channel.postMessage({
type: 'STOMP_EVENT',
payload: JSON.parse(message.body)
});
});
channel.onmess age = (event) => {
if (event.data.type === 'STOMP_EVENT') {
renderMessage(event.data.payload);
}
};
Теперь только одна вкладка использует WebSocket.
Только лидер имеет STOMP-подключение.
Остальные вкладки должны передавать команды лидеру.
function sendChatMessage(text) {
channel.postMessage({
type: 'SEND_MESSAGE',
payload: {
text
}
});
}
channel.onmess age = (event) => {
if (event.data.type === 'SEND_MESSAGE') {
client.publish({
destination: '/app/chat',
body: JSON.stringify(event.data.payload)
});
}
};
Часто используется единый store состояния.
Например:
const state = {
connected: false,
messages: [],
users: []
};
Лидер обновляет store:
function updateState(data) {
state.messages.push(data);
channel.postMessage({
type: 'STATE_UPDATE',
payload: state
});
}
channel.onmess age = (event) => {
if (event.data.type === 'STATE_UPDATE') {
hydrate(event.data.payload);
}
};
Если токен обновляется в одной вкладке, остальные могут использовать устаревшее значение.
channel.postMessage({
type: 'TOKEN_UPDATED',
payload: {
accessToken
}
});
channel.onmess age = (event) => {
if (event.data.type === 'TOKEN_UPDATED') {
authStore.setToken(
event.data.payload.accessToken
);
}
};
client.onConn ect = () => {
channel.postMessage({
type: 'CONNECTED'
});
};
client.onDisconn ect = () => {
channel.postMessage({
type: 'DISCONNECTED'
});
};
channel.onmess age = (event) => {
switch (event.data.type) {
case 'CONNECTED':
showOnline();
break;
case 'DISCONNECTED':
showOffline();
break;
}
};
Только лидер управляет подписками.
const subscriptions = new Map();
channel.postMessage({
type: 'SUBSCRIBE',
payload: {
destination: '/topic/orders'
}
});
channel.onmess age = (event) => {
if (event.data.type === 'SUBSCRIBE') {
const destination =
event.data.payload.destination;
if (!subscriptions.has(destination)) {
const sub = client.subscribe(
destination,
handleMessage
);
subscriptions.set(destination, sub);
}
}
};
channel.postMessage({
type: 'UNSUBSCRIBE',
payload: {
destination: '/topic/orders'
}
});
channel.onmess age = (event) => {
if (event.data.type === 'UNSUBSCRIBE') {
const sub = subscriptions.get(
event.data.payload.destination
);
if (sub) {
sub.unsubscribe();
subscriptions.delete(
event.data.payload.destination
);
}
}
};
window.addEventListener('storage', (event) => {
if (event.key === 'stomp-message') {
const data = JSON.parse(event.newValue);
console.log(data);
}
});
localStorage.setItem(
'stomp-message',
JSON.stringify({
text: 'hello'
})
);
Недостатки подхода:
BroadcastChannel значительно удобнее.
SharedWorker позволяет создать единый JavaScript-контекст для всех вкладок.
Архитектура:
Tabs
↓
SharedWorker
↓
STOMP.js
↓
Broker
const connections = [];
onconn ect = (event) => {
const port = event.ports[0];
connections.push(port);
port.start();
};
const worker = new SharedWorker(
'/stomp-worker.js'
);
worker.port.start();
importScripts('/stomp.umd.js');
const client = new StompJs.Client({
brokerURL: 'ws://localhost:15674/ws'
});
client.activate();
client.subscribe('/topic/chat', (message) => {
connections.forEach((port) => {
port.postMessage(message.body);
});
});
| Преимущество | Описание |
|---|---|
| Одно соединение | Только один WebSocket |
| Общая память | Централизованное состояние |
| Нет leader election | Worker всегда единый |
| Производительность | Меньше нагрузки |
| Удобство | Простая архитектура |
| Недостаток | Описание |
|---|---|
| Поддержка браузеров | Ограниченная совместимость |
| Сложность отладки | Worker-debugging |
| HTTPS | Требуется secure context |
| CSP | Возможны ограничения |
Service Worker плохо подходит для постоянных WebSocket-подключений.
Причины:
Поэтому SharedWorker предпочтительнее.
Web Locks API позволяет безопасно выбирать лидера.
navigator.locks.request(
'stomp-leader-lock',
async (lock) => {
startLeaderMode();
}
);
window.addEventListener('beforeunload', () => {
if (isLeader) {
channel.postMessage({
type: 'LEADER_LEFT'
});
}
});
channel.onmess age = (event) => {
if (event.data.type === 'LEADER_LEFT') {
tryBecomeLeader();
}
};
Во время reconnect сообщения могут временно храниться:
const pendingMessages = [];
function enqueue(message) {
pendingMessages.push(message);
}
client.onConn ect = () => {
while (pendingMessages.length) {
const msg = pendingMessages.shift();
client.publish(msg);
}
};
channel.postMessage({
type: 'OFFLINE_MODE'
});
channel.onmess age = (event) => {
if (event.data.type === 'OFFLINE_MODE') {
enableOfflineUI();
}
};
Во время reconnect могут приходить дубликаты сообщений.
const processed = new Set();
function handleMessage(message) {
const id = message.headers['message-id'];
if (processed.has(id)) {
return;
}
processed.add(id);
processMessage(message);
}
Heartbeat должен отправлять только лидер.
const client = new Client({
brokerURL: 'ws://localhost:15674/ws',
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000
});
Если heartbeat будут отправлять все вкладки:
Типичные race conditions:
| Проблема | Причина |
|---|---|
| Два лидера | Одновременный election |
| Потеря сообщений | Переключение лидера |
| Дублирование publish | Повторная отправка |
| Двойные подписки | Несогласованность состояния |
const leaderData = {
id: TAB_ID,
timestamp: Date.now()
};
const current = JSON.parse(
localStorage.getItem('leader')
);
if (
!current ||
current.timestamp < leaderData.timestamp
) {
localStorage.setItem(
'leader',
JSON.stringify(leaderData)
);
}
При большом количестве вкладок полезно:
const batch = [];
function pushMessage(message) {
batch.push(message);
}
setInterval(() => {
if (!batch.length) {
return;
}
channel.postMessage({
type: 'BATCH',
payload: [...batch]
});
batch.length = 0;
}, 100);
function log(...args) {
console.log(
'[STOMP_SYNC]',
...args
);
}
Полезно отслеживать:
Наиболее устойчивой считается следующая схема:
Browser Tabs
↓
BroadcastChannel
↓
Leader Tab
↓
STOMP.js
↓
RabbitMQ / ActiveMQ / Apollo
Дополнительно:
Подобная архитектура обеспечивает: