Обновление данных на странице

Обновление данных на странице в реальном времени — одна из ключевых задач при использовании WebSocket-приложений. Библиотека STOMP.js позволяет организовать постоянный поток сообщений между клиентом и сервером без необходимости выполнять регулярные HTTP-запросы.

Наиболее распространённые сценарии:

  • обновление списка сообщений в чате;
  • изменение биржевых котировок;
  • отображение уведомлений;
  • обновление статистики;
  • синхронизация состояния между пользователями;
  • онлайн-мониторинг оборудования;
  • обновление таблиц и графиков.

STOMP.js работает поверх WebSocket и позволяет подписываться на каналы, из которых сервер отправляет события клиенту.

Базовая схема работы выглядит следующим образом:

  1. Клиент подключается к WebSocket-серверу.
  2. STOMP-клиент выполняет подключение по протоколу STOMP.
  3. Клиент подписывается на нужный destination.
  4. Сервер публикует сообщения.
  5. Клиент получает события и обновляет DOM.

Подключение STOMP.js

Установка через npm:

npm install @stomp/stompjs

Подключение клиента:

import { Client } from '@stomp/stompjs';

const client = new Client({
    brokerURL: 'ws://localhost:8080/ws'
});

Активация соединения:

client.activate();

Обновление текста на странице

Самый простой вариант — обновление содержимого элемента при получении сообщения.

HTML:

<div id="status">Нет данных</div>

Jav * ascript:

import { Client } from '@stomp/stompjs';

const statusElement = document.getElementById('status');

const client = new Client({
    brokerURL: 'ws://localhost:8080/ws',

    onConnect: () => {
        client.subscribe('/topic/status', message => {
            statusElement.textContent = message.body;
        });
    }
});

client.activate();

При каждом новом сообщении содержимое блока автоматически изменяется.


Обновление JSON-данных

Чаще всего сервер отправляет JSON.

Пример сообщения:

{
    "name": "CPU",
    "value": 74
}

Обработка:

client.subscribe('/topic/metrics', message => {
    const data = JSON.parse(message.body);

    document.getElementById('metric-name').textContent = data.name;
    document.getElementById('metric-value').textContent = data.value;
});

HTML:

<div>
    <span id="metric-name"></span>
    <span id="metric-value"></span>
</div>

Обновление списка элементов

Один из наиболее распространённых сценариев — потоковое обновление списка.

HTML:

<ul id="messages"></ul>

Jav * ascript:

const messagesElement = document.getElementById('messages');

client.subscribe('/topic/chat', message => {
    const data = JSON.parse(message.body);

    const li = document.createElement('li');
    li.textContent = data.text;

    messagesElement.appendChild(li);
});

Каждое сообщение добавляется в DOM без перезагрузки страницы.


Ограничение размера списка

При длительной работе страницы количество элементов может стать слишком большим.

Пример ограничения:

const MAX_MESSAGES = 100;

client.subscribe('/topic/chat', message => {
    const data = JSON.parse(message.body);

    const li = document.createElement('li');
    li.textContent = data.text;

    messagesElement.appendChild(li);

    while (messagesElement.children.length > MAX_MESSAGES) {
        messagesElement.removeChild(messagesElement.firstChild);
    }
});

Такой подход предотвращает переполнение DOM.


Полное обновление блока

Иногда сервер отправляет полностью готовое состояние.

Пример:

client.subscribe('/topic/users', message => {
    const users = JSON.parse(message.body);

    const container = document.getElementById('users');

    container.innerHTML = '';

    users.forEach(user => {
        const div = document.createElement('div');
        div.textContent = user.name;

        container.appendChild(div);
    });
});

Этот вариант удобен для небольших наборов данных.


Частичное обновление данных

При больших объёмах данных выгоднее обновлять только изменившиеся элементы.

Структура HTML:

<div id="products"></div>

Обработка:

client.subscribe('/topic/product-update', message => {
    const product = JSON.parse(message.body);

    let row = document.getElementById(`product-${product.id}`);

    if (!row) {
        row = document.createElement('div');
        row.id = `product-${product.id}`;

        document.getElementById('products').appendChild(row);
    }

    row.textContent = `${product.name}: ${product.price}`;
});

Такой подход уменьшает нагрузку на браузер.


Использование шаблонов

Для сложного интерфейса удобнее использовать шаблоны.

Функция рендеринга:

function renderUser(user) {
    return `
        <div class="user">
            <div>${user.name}</div>
            <div>${user.status}</div>
        </div>
    `;
}

Обновление:

client.subscribe('/topic/user-status', message => {
    const user = JSON.parse(message.body);

    const html = renderUser(user);

    const container = document.getElementById('users');

    container.insertAdjacentHTML('beforeend', html);
});

Обновление таблицы

HTML:

<table>
    <tbody id="table-body"></tbody>
</table>

Jav * ascript:

client.subscribe('/topic/orders', message => {
    const order = JSON.parse(message.body);

    const tr = document.createElement('tr');

    tr.innerHTML = `
        <td>${order.id}</td>
        <td>${order.status}</td>
        <td>${order.price}</td>
    `;

    document.getElementById('table-body').appendChild(tr);
});

Замена существующих строк таблицы

client.subscribe('/topic/order-update', message => {
    const order = JSON.parse(message.body);

    let row = document.getElementById(`order-${order.id}`);

    if (!row) {
        row = document.createElement('tr');
        row.id = `order-${order.id}`;

        document.getElementById('table-body').appendChild(row);
    }

    row.innerHTML = `
        <td>${order.id}</td>
        <td>${order.status}</td>
        <td>${order.price}</td>
    `;
});

Обновление счётчиков

HTML:

<div id="online-count">0</div>

Jav * ascript:

client.subscribe('/topic/online', message => {
    const data = JSON.parse(message.body);

    document.getElementById('online-count').textContent = data.count;
});

Обновление графиков

STOMP.js часто используется совместно с графическими библиотеками.

Пример с Chart.js:

client.subscribe('/topic/chart', message => {
    const point = JSON.parse(message.body);

    chart.data.labels.push(point.time);
    chart.data.datasets[0].data.push(point.value);

    chart.update();
});

Обновление карточек товаров

HTML:

<div id="catalog"></div>

Jav * ascript:

client.subscribe('/topic/catalog', message => {
    const product = JSON.parse(message.body);

    let card = document.getElementById(`card-${product.id}`);

    if (!card) {
        card = document.createElement('div');
        card.id = `card-${product.id}`;

        document.getElementById('catalog').appendChild(card);
    }

    card.innerHTML = `
        <h3>${product.name}</h3>
        <p>${product.price}</p>
        <p>${product.stock}</p>
    `;
});

Работа с большим количеством обновлений

При высокой частоте сообщений возникает проблема постоянного перерисовывания DOM.

Плохой пример:

client.subscribe('/topic/live', message => {
    element.textContent = message.body;
});

Если сервер отправляет сотни сообщений в секунду, браузер начнёт тормозить.


Буферизация обновлений

Эффективный способ — накопление сообщений.

const queue = [];

client.subscribe('/topic/live', message => {
    queue.push(JSON.parse(message.body));
});

Обработка очереди:

setInterval(() => {
    if (queue.length === 0) {
        return;
    }

    const batch = queue.splice(0, queue.length);

    batch.forEach(item => {
        updateUI(item);
    });
}, 100);

Использование requestAnimationFrame

let latestData = null;

client.subscribe('/topic/live', message => {
    latestData = JSON.parse(message.body);
});

function render() {
    if (latestData) {
        updateUI(latestData);
        latestData = null;
    }

    requestAnimationFrame(render);
}

render();

Такой подход синхронизирует обновления с циклом рендеринга браузера.


Избежание полного innerHTML

Частая ошибка:

container.innerHTML += `
    <div>${message.body}</div>
`;

Недостатки:

  • полная перерисовка;
  • потеря обработчиков событий;
  • ухудшение производительности;
  • утечки памяти.

Предпочтительный вариант:

const div = document.createElement('div');
div.textContent = message.body;

container.appendChild(div);

Использование DocumentFragment

Для массового обновления DOM:

client.subscribe('/topic/bulk', message => {
    const items = JSON.parse(message.body);

    const fragment = document.createDocumentFragment();

    items.forEach(item => {
        const div = document.createElement('div');

        div.textContent = item.name;

        fragment.appendChild(div);
    });

    container.appendChild(fragment);
});

Очистка подписок

Если компонент удаляется со страницы, подписку необходимо закрывать.

const subscription = client.subscribe('/topic/chat', callback);

subscription.unsubscribe();

Без этого возможны:

  • утечки памяти;
  • дублирование сообщений;
  • повторный рендеринг;
  • рост нагрузки на браузер.

Повторное подключение и обновление страницы

При потере соединения данные могут перестать обновляться.

STOMP.js поддерживает автоматический reconnect.

const client = new Client({
    brokerURL: 'ws://localhost:8080/ws',
    reconnectDelay: 5000
});

После восстановления соединения подписки создаются заново внутри onConnect.

onConnect: () => {
    client.subscribe('/topic/data', callback);
}

Индикация состояния соединения

HTML:

<div id="connection-status"></div>

Jav * ascript:

const statusElement = document.getElementById('connection-status');

const client = new Client({
    brokerURL: 'ws://localhost:8080/ws',

    onConnect: () => {
        statusElement.textContent = 'Connected';
    },

    onDisconnect: () => {
        statusElement.textContent = 'Disconnected';
    },

    onStompError: frame => {
        statusElement.textContent = 'Error';
    }
});

Дебаунс обновлений

Иногда сервер отправляет слишком много одинаковых событий.

Пример debounce:

let timeout = null;

client.subscribe('/topic/search', message => {
    const data = JSON.parse(message.body);

    clearTimeout(timeout);

    timeout = setTimeout(() => {
        render(data);
    }, 300);
});

Хранение состояния отдельно от DOM

Нежелательно использовать DOM как источник данных.

Плохой подход:

const current = element.textContent;

Лучше хранить состояние отдельно:

const state = {
    users: []
};

Обновление:

client.subscribe('/topic/users', message => {
    state.users = JSON.parse(message.body);

    renderUsers(state.users);
});

Интеграция с React

useEffect(() => {
    const client = new Client({
        brokerURL: 'ws://localhost:8080/ws',

        onConnect: () => {
            client.subscribe('/topic/messages', message => {
                const data = JSON.parse(message.body);

                setMessages(prev => [...prev, data]);
            });
        }
    });

    client.activate();

    return () => {
        client.deactivate();
    };
}, []);

Интеграция с Vue

onMounted(() => {
    client.activate();
});

onUnmounted(() => {
    client.deactivate();
});

Подписка:

client.subscribe('/topic/data', message => {
    items.value.push(JSON.parse(message.body));
});

Интеграция с Angular

this.client.subscribe('/topic/data', message => {
    const data = JSON.parse(message.body);

    this.items.push(data);
});

Использование виртуализации

При огромных объёмах данных полезно отображать только видимую часть списка.

Проблемы без виртуализации:

  • тысячи DOM-элементов;
  • медленная прокрутка;
  • высокий расход памяти;
  • зависания интерфейса.

В таких случаях используются:

  • react-window;
  • react-virtualized;
  • vue-virtual-scroller;
  • Angular CDK Virtual Scroll.

Синхронизация нескольких блоков интерфейса

Одно сообщение может обновлять разные части страницы.

client.subscribe('/topic/dashboard', message => {
    const data = JSON.parse(message.body);

    updateChart(data.chart);
    updateTable(data.table);
    updateStats(data.stats);
});

Централизованный обработчик событий

Для крупных приложений полезно разделять транспорт и UI.

client.subscribe('/topic/events', message => {
    const event = JSON.parse(message.body);

    eventBus.emit(event.type, event.payload);
});

Обработчики:

eventBus.on('USER_UPDATED', updateUser);
eventBus.on('NEW_ORDER', addOrder);
eventBus.on('PRICE_CHANGED', updatePrice);

Оптимизация производительности

Основные рекомендации:

  • минимизировать количество изменений DOM;
  • обновлять только изменённые элементы;
  • избегать постоянного innerHTML;
  • использовать буферизацию;
  • ограничивать размер списков;
  • очищать подписки;
  • хранить состояние отдельно;
  • группировать обновления;
  • использовать requestAnimationFrame;
  • применять виртуализацию для больших списков.

Типичная архитектура realtime-обновлений

Структура приложения:

WebSocket
    ↓
STOMP.js
    ↓
Message Handler
    ↓
State Store
    ↓
UI Renderer
    ↓
DOM

Такой подход обеспечивает:

  • масштабируемость;
  • предсказуемость;
  • удобство поддержки;
  • высокую производительность;
  • устойчивость к большому потоку сообщений.