Как подружить React с 100 000 live-обновлений: WebSocket, Web Workers и кастомный стор
- суббота, 24 октября 2026 г. в 00:00:12
Введение
Представьте, что вам нужно отрендерить в React-приложении таблицу с 100 000 объектов, которые непрерывно обновляются в реальном времени через WebSocket. Каждые 50 мс прилетает батч из 5000 дельт (изменений).
Стандартный подход с useState или классическим Redux (с Immer) убивает браузер за несколько секунд:
Main Thread блокируется: Парсинг JSON и вычисление диффов занимают сотни миллисекунд.
Garbage Collector сходит с ума: Иммутабельные обновления создают тысячи новых объектов-клонов, вызывая фризы при сборке мусора.
Ад ререндеров: Обновление одного элемента триггерит перерисовку всех 100 000 компонентов.
В этой статье я расскажу, как спроектировать архитектуру, которая переварит этот поток и сохранит стабильные 60 FPS.
Мы разделили путь данных на три зоны:
Транспорт (WebSocket + Worker): Принимает бинарные данные и батчит их.
Хранилище (Live Store): Живет вне React, хранит данные нормализованно и делает точечные мутации.
UI (React + Virtualization): Подписан только на те данные, которые видны на экране.
Парсинг 100 000 JSON-объектов в onmessage забивает главный поток. Мы использовали бинарный протокол (ArrayBuffer + DataView), где данные приходят в виде байтовых структур.
Размер сообщения уменьшился в 3–4 раза.
Парсинг происходит в отдельном потоке.
Как только WebSocket получает бинарный фрейм, мы трансферим (передаем без копирования) ArrayBuffer в Web Worker:
ws.binaryType = 'arraybuffer'; ws.onmessage = (e) => { // Передаем буфер в воркер (zero-copy) worker.postMessage({ type: 'frame', buf: e.data }, [e.data]); };
В воркере мы декодируем данные и батчим их. Если сервер шлет обновления слишком часто, мы накапливаем их и отправляем в главный поток не чаще одного раза в 16 мс (один кадр). Это позволяет UI дышать.
Главная ошибка — хранить высокочастотные данные в Redux. Иммутабельность (Immer) для 100K объектов — это приговор. Мы написали кастомный PointsLiveStore.
Вместо создания новых объектов мы изменяем существующие прямо в 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 компонентов даже не узнают об обновлении.
В 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 рендерит только видимую область.
Когда пользователь нажимает «Выбрать все», нельзя создавать 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. Этот подход требует дисциплины и понимания жизненного цикла.