Работа с STOMP поверх WebSocket почти всегда приводит к необходимости централизованного управления соединением. Причина проста: WebSocket-подключение — это дорогостоящий ресурс, а STOMP-клиент поверх него содержит состояние подписок, очередь отправки сообщений и логику переподключения. Дублирование таких клиентов в рамках одного приложения приводит к рассинхронизации состояния, множественным подпискам на одни и те же топики и трудноотлавливаемым багам.
Singleton в контексте STOMP.js решает задачу обеспечения единственного экземпляра клиента, который используется во всём приложении.
STOMP-клиент в браузере обычно инкапсулирует:
При создании нескольких экземпляров возникают проблемы:
Singleton гарантирует:
Наиболее простой и часто используемый вариант — использование особенностей ES Modules. Модуль в JavaScript кэшируется, и все импорты получают одну и ту же ссылку на объект.
// stompClient.js
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
let client = null;
export function getStompClient() {
if (client) {
return client;
}
client = new Client({
webSocketFactory: () => new SockJS('/ws'),
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
});
client.activate();
return client;
}
export function getRawClient() {
return client;
}
Ключевая идея — ленивое создание экземпляра. Клиент создаётся только при первом обращении, после чего возвращается один и тот же объект.
Когда требуется более формализованная структура (например, в крупных архитектурах или при использовании TypeScript), применяется классический Singleton через статическое поле.
import { Client } from '@stomp/stompjs';
import SockJS from 'sockjs-client';
export class StompService {
static instance = null;
constructor() {
if (StompService.instance) {
return StompService.instance;
}
this.client = new Client({
webSocketFactory: () => new SockJS('/ws'),
reconnectDelay: 5000,
});
this.subscriptions = new Map();
StompService.instance = this;
}
connect() {
if (!this.client.active) {
this.client.activate();
}
}
disconnect() {
this.client.deactivate();
}
subscribe(destination, callback) {
const sub = this.client.subscribe(destination, callback);
this.subscriptions.set(destination, sub);
return sub;
}
unsubscribe(destination) {
const sub = this.subscriptions.get(destination);
if (sub) {
sub.unsubscribe();
this.subscriptions.delete(destination);
}
}
}
Такой подход удобен, когда необходимо расширять функциональность: добавлять middleware для сообщений, логирование или управление подписками.
В STOMP.js важно учитывать момент подключения. Клиент может быть создан, но ещё не активирован. Singleton должен различать:
Распространённый подход — хранить Promise готовности:
let client = null;
let connectPromise = null;
export function initClient() {
if (client) return client;
client = new Client({
webSocketFactory: () => new SockJS('/ws'),
reconnectDelay: 5000,
});
connectPromise = new Promise((resolve) => {
client.onConn ect = () => resolve(client);
});
client.activate();
return client;
}
export function whenConnected() {
return connectPromise;
}
Это позволяет избежать ситуации, когда подписка выполняется до фактического подключения.
Одной из ключевых задач становится контроль подписок. При множественных вызовах компонентов важно не создавать дубликаты.
Решение — централизованный реестр:
class SubscriptionRegistry {
constructor(client) {
this.client = client;
this.subscriptions = new Map();
}
subscribe(destination, handler) {
if (this.subscriptions.has(destination)) {
return this.subscriptions.get(destination);
}
const sub = this.client.subscribe(destination, handler);
this.subscriptions.set(destination, sub);
return sub;
}
unsubscribe(destination) {
const sub = this.subscriptions.get(destination);
if (sub) {
sub.unsubscribe();
this.subscriptions.delete(destination);
}
}
clear() {
this.subscriptions.forEach((sub) => sub.unsubscribe());
this.subscriptions.clear();
}
}
Такой слой особенно полезен в SPA, где компоненты монтируются и размонтируются часто.
STOMP.js поддерживает автоматическое переподключение, но Singleton должен учитывать восстановление подписок после reconnect.
Типичная проблема: после переподключения сервер считает соединение новым, и все подписки теряются.
Решение — хранение декларативного списка подписок:
const registry = new Map();
function subscribe(destination, callback) {
registry.set(destination, callback);
return client.subscribe(destination, callback);
}
client.onConn ect = () => {
registry.forEach((callback, destination) => {
client.subscribe(destination, callback);
});
};
Таким образом Singleton превращается в источник истины по состоянию подписок.
Даже при использовании Singleton в коде могут появляться скрытые дубликаты:
Для защиты используется дополнительный глобальный реестр:
const GLOBAL_KEY = '__STOMP_SINGLETON__';
export function getClient() {
if (window[GLOBAL_KEY]) {
return window[GLOBAL_KEY];
}
const client = new Client({...});
window[GLOBAL_KEY] = client;
return client;
}
Это особенно важно в микрофронтендах.
STOMP-клиент часто становится частью более широкой системы управления состоянием:
В таких случаях Singleton используется как низкоуровневый транспорт, а не как точка бизнес-логики.
Пример интеграции с store:
import { readable } from 'svelte/store';
export const messages = readable([], (set) => {
const client = getStompClient();
const sub = client.subscribe('/topic/messages', (msg) => {
set(JSON.parse(msg.body));
});
return () => sub.unsubscribe();
});
Singleton обеспечивает стабильность транспорта, store — реактивность.
Singleton усложняет изоляцию тестов, так как состояние сохраняется между кейсами.
Типовые решения:
Пример reset:
export function resetClient() {
if (client) {
client.deactivate();
}
client = null;
}
В тестах это позволяет начинать каждый сценарий с чистого состояния.
В крупных приложениях Singleton часто заменяется DI-контейнером:
export function createStompClient() {
return new Client({
webSocketFactory: () => new SockJS('/ws'),
});
}
И уже приложение решает, будет ли это Singleton или нет.
Такой подход снижает связанность и улучшает тестируемость, но увеличивает сложность архитектуры.
В Node.js среде Singleton ведёт себя иначе:
Поэтому логика Singleton должна быть адаптирована:
const isBrowser = typeof window !== 'undefined';
export const getClient = () => {
if (!isBrowser) {
return createNewClient();
}
if (!window.__client) {
window.__client = createNewClient();
}
return window.__client;
};
Это предотвращает утечки соединений между запросами.
Критически важный аспект — явное состояние клиента:
Без этого Singleton превращается в «чёрный ящик».
Пример расширенного контроля:
class StompSingleton {
state = 'disconnected';
connect() {
this.state = 'connecting';
this.client.onConn ect = () => {
this.state = 'connected';
};
this.client.onStompEr ror = () => {
this.state = 'error';
};
}
}
Это позволяет внешнему коду принимать решения без прямого доступа к внутренностям STOMP.js.
В зрелой реализации Singleton-клиент выполняет сразу несколько ролей: