Представь задачу из мира высоконагруженного финтеха или мониторинга: в браузере открыта таблица на 100 000 позиций. По протоколу WebSocket с сервера непрерывно летят котировки — пачками по 5 000 дельт каждые 50 миллисекунд. В сумме это даёт 100 000 обновлений значений полей в секунду. При этом интерфейс должен оставаться отзывчивым: плавная прокрутка 60 FPS, моментальная реакция на клики и никаких фризов.
Если попытаться решить эту задачу стандартными средствами React — положить данные в useState, подключить Redux Toolkit с Immer или Zustand — вкладка браузера зависнет намертво в первую же секунду после установки соединения.
В практическом разборе на Хабре автор rusaleffa препарирует причины мгновенного коллапса стандартного стейт-менеджмента и показывает, как трёхзвенная архитектура с Web Workers спасает производительность интерфейса.
Анатомия коллапса: почему useState и Redux убивают главный поток браузера
В браузере JavaScript исполняется в едином главном потоке (Main Thread). Этот поток делит время процессора между выполнением кода, расчётом стилей и отрисовкой кадров (бюджет — 16.6 мс на кадр для 60 FPS).
Когда в главный поток прилетает пачка из 5 000 JSON-объектов, происходят три события:
- Блокировка парсингом: разбор текстового JSON такого объёма через
JSON.parseзанимает от 30 до 70 мс, намертво блокируя интерфейс. - Паузы сборщика мусора: Redux и React требуют создания новых копий объектов при каждом изменении (
{ ...item, price }). Создание 100 000 объектов в секунду перегружает кучу. Сборщик мусора включает Major GC, замораживая поток на сотни миллисекунд. - Каскадный перерендер: изменение глобального состояния заставляет React сверять дерево виртуального DOM по тысячам селекторов.
Бинарный протокол и вынос сетевого парсинга в выделенный Web Worker
Первый шаг — убрать сетевую рутину и парсинг из главного потока, открывая WebSocket внутри фонового потока Dedicated Web Worker.
Второй шаг — отказ от текстового JSON в пользу бинарной упаковки данных через ArrayBuffer и DataView. Каждая дельта пакуется всего в 16 байт: 4 байта на id, 8 байт на price и 4 байта на volume. Сетевой трафик падает в 3–4 раза.
Воркер принимает бинарный поток и пересылает буфер в главный поток:
// worker.js - фоновый поток обработки телеметрии
let socket = null;
self.onmessage = (event) => {
if (event.data.type === 'CONNECT') {
socket = new WebSocket(event.data.url);
socket.binaryType = 'arraybuffer';
socket.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// Передаем буфер в Main Thread по схеме Zero-Copy (Transferable Objects)
// Память не копируется, а передается по указателю владения!
self.postMessage({ type: 'BATCH_UPDATE', buffer: event.data }, [event.data]);
}
};
}
};
Второй аргумент [event.data] в вызове postMessage задействует механизм Transferable Objects: браузер не клонирует байты, а мгновенно передаёт право владения памятью из воркера в главный поток за нулевое время (Zero-Copy).
Zero-Copy через Transferable Objects и мутабельный стор вне дерева React
В главном потоке мы отказываемся от создания иммутабельных копий объектов. Все 100 000 строк хранятся в нативном JavaScript-объекте Map за пределами контекста React.
Обновления применяются по месту (In-Place Mutation) с инкрементом версии строки:
// store.ts - высокопроизводительное хранилище вне дерева компонентов
export interface DataItem {
id: number;
price: number;
volume: number;
version: number;
}
type Subscriber = (version: number) => void;
export class LiveDataStore {
private data = new Map<number, DataItem>();
private subscribers = new Map<number, Set<Subscriber>>();
private pendingUpdates = new Set<number>();
private rafScheduled = false;
public applyBinaryBuffer(buffer: ArrayBuffer) {
const view = new DataView(buffer);
const count = view.byteLength / 16;
for (let i = 0; i < count; i++) {
const offset = i * 16;
const id = view.getInt32(offset, true);
const price = view.getFloat64(offset + 4, true);
const volume = view.getInt32(offset + 12, true);
const entity = this.data.get(id);
if (entity) {
entity.price = price;
entity.volume = volume;
entity.version++;
this.pendingUpdates.add(id);
}
}
this.scheduleBatchFlush();
}
private scheduleBatchFlush() {
if (this.rafScheduled) return;
this.rafScheduled = true;
requestAnimationFrame(() => {
this.pendingUpdates.forEach((id) => {
const entity = this.data.get(id);
const subs = this.subscribers.get(id);
if (entity && subs) {
subs.forEach((cb) => cb(entity.version));
}
});
this.pendingUpdates.clear();
this.rafScheduled = false;
});
}
public subscribe(id: number, cb: Subscriber) {
if (!this.subscribers.has(id)) {
this.subscribers.set(id, new Set());
}
this.subscribers.get(id)!.add(cb);
return () => this.subscribers.get(id)?.delete(cb);
}
}
Никогда не вызывайте рендер на каждый входящий сетевой пакет. Складывайте дельты в очередь и сбрасывайте изменения строго один раз за кадр через requestAnimationFrame.
Синхронизация по requestAnimationFrame и виртуализация строк
На экране через виртуализатор отображаются только те 30–50 строк таблицы, которые попадают в область видимости монитора.
Каждый компонент строки подписывается через useSyncExternalStore строго на свой id. Когда в сторе мутирует строка №42, уведомление получает только её компонент, перерисовывая две цифры на экране. Остальные строки даже не просыпаются.
Результат впечатляет: фоновый Web Worker берёт на себя приём сокетов на вторичном ядре, мутабельный стор устраняет паузы сборщика мусора, а React легко удерживает 60 FPS под напором 100 000 апдейтов в секунду.
