javascript

Как подружить React с 100 000 live-обновлений: WebSocket, Web Workers и кастомный стор

  • суббота, 24 октября 2026 г. в 00:00:12
https://habr.com/ru/articles/1098484/

Введение

Представьте, что вам нужно отрендерить в React-приложении таблицу с 100 000 объектов, которые непрерывно обновляются в реальном времени через WebSocket. Каждые 50 мс прилетает батч из 5000 дельт (изменений).

Стандартный подход с useState или классическим Redux (с Immer) убивает браузер за несколько секунд:

  1. Main Thread блокируется: Парсинг JSON и вычисление диффов занимают сотни миллисекунд.

  2. Garbage Collector сходит с ума: Иммутабельные обновления создают тысячи новых объектов-клонов, вызывая фризы при сборке мусора.

  3. Ад ререндеров: Обновление одного элемента триггерит перерисовку всех 100 000 компонентов.

В этой статье я расскажу, как спроектировать архитектуру, которая переварит этот поток и сохранит стабильные 60 FPS.


1. Архитектура: Разделяй и властвуй

Мы разделили путь данных на три зоны:

  • Транспорт (WebSocket + Worker): Принимает бинарные данные и батчит их.

  • Хранилище (Live Store): Живет вне React, хранит данные нормализованно и делает точечные мутации.

  • UI (React + Virtualization): Подписан только на те данные, которые видны на экране.


2. Транспорт: WebSocket, Бинарный протокол и Web Worker

Почему не JSON?

Парсинг 100 000 JSON-объектов в onmessage забивает главный поток. Мы использовали бинарный протокол (ArrayBuffer + DataView), где данные приходят в виде байтовых структур.

  • Размер сообщения уменьшился в 3–4 раза.

  • Парсинг происходит в отдельном потоке.

Web Worker

Как только WebSocket получает бинарный фрейм, мы трансферим (передаем без копирования) ArrayBuffer в Web Worker:

ws.binaryType = 'arraybuffer';
ws.onmessage = (e) => {
  // Передаем буфер в воркер (zero-copy)
  worker.postMessage({ type: 'frame', buf: e.data }, [e.data]);
};

В воркере мы декодируем данные и батчим их. Если сервер шлет обновления слишком часто, мы накапливаем их и отправляем в главный поток не чаще одного раза в 16 мс (один кадр). Это позволяет UI дышать.


3. Главный секрет: Live Store вместо Redux

Главная ошибка — хранить высокочастотные данные в Redux. Иммутабельность (Immer) для 100K объектов — это приговор. Мы написали кастомный PointsLiveStore.

Магия in-place мутаций

Вместо создания новых объектов мы изменяем существующие прямо в Map. Ссылка на объект остается прежней, но мы инкрементируем его «версию», чтобы React понял, что данные изменились.

class PointsLiveStore {
  private entities = new Map<number, Item>();
  private versions = new Map<number, number>();

  applyPatches(patches: Patch[]) {
    for (const p of patches) {
      const existing = this.entities.get(p.id);
      if (existing) {
        Object.assign(existing, p); // Мутация in-place
        this.versions.set(p.id, (this.versions.get(p.id) || 0) + 1);
        this.emitRow(p.id); // Оповещаем только эту строку
      }
    }
  }
}

Точечные подписки

Store хранит Map с коллбэками для каждой строки. Когда патч приходит для ID 42, дергается коллбэк только для ID 42. Остальные 99 999 компонентов даже не узнают об обновлении.


4. React и useSyncExternalStore

В React 18 появился хук useSyncExternalStore — идеальный инструмент для подписки на внешние кастомные сторы. Он гарантирует консистентность данных и не вызывает лишних ререндеров.

Каждая карточка (строка) подписывается на свой ID:

export const Row = memo(({ id }) => {
  const version = useSyncExternalStore(
    (cb) => store.subscribeRow(id, cb),
    () => store.getVersion(id),
    () => 0
  );

  // Берем данные напрямую (они мутировали in-place)
  const item = store.getItem(id);

  return <div>{item.name} — {item.value}</div>;
});

Дополнительно мы используем @tanstack/react-virtual. В DOM всегда находится ~30-50 элементов. Даже если в сторе их 100 000, React рендерит только видимую область.


5. Ловушка «Выбрать всё» (Select All) на 100 000 элементов

Когда пользователь нажимает «Выбрать все», нельзя создавать Set из 100 000 ID — это заблокирует поток на сотни миллисекунд и съест память.

Мы используем паттерн Флаг + Исключения:

  • Храним булев флаг selectAllActive.

  • Если он true, элемент считается выбранным по умолчанию.

  • Если пользователь снял галочку с одного элемента, мы добавляем его ID в Set с исключениями (excluded).

isSelected(id: number) {
  return this.selectAllActive 
    ? !this.excluded.has(id) 
    : this.selection.has(id);
}

Счетчик выбранных элементов работает за O(1): total - excluded.size.


Итоги и метрики

В результате мы получили дашборд, который:

  • Держит 100 000+ активных сущностей в памяти.

  • Обрабатывает поток в 10 000+ обновлений в секунду.

  • Показывает стабильные 60 FPS и отклик интерфейса < 16 мс.

  • Не блокирует основной поток парсингом JSON.

Когда это нужно? В трейдинге, системах мониторинга, MES-системах.
Когда не нужно? Если у вас 1000 элементов и 1 обновление в секунду — оставьте Redux и RTK Query. Этот подход требует дисциплины и понимания жизненного цикла.