Архитектура браузерного JavaScript является строго однопоточной (Single-Threaded Event Loop). Главный поток (Main Thread) отвечает одновременно за исполнение бизнес-логики, фазы Reconciliation и Commit виртуального DOM React 19, обработку событий ввода пользователя (клики, нажатия клавиш, жесты, скролл), расчет CSS-геометрии (Layout) и отрисовку пикселей на экране (Paint).
Если в Main Thread запускается синхронная вычислительная операция длительностью более 50 миллисекунд (Long Task), интерфейс браузера полностью «замораживается»:
Единственным фундаментальным решением для тяжелых математических, парсинговых и графических задач является использование Web Workers (Dedicated Workers).
Для устранения архаичного низкоуровневого API postMessage / onmessage и избавления от шаблонного кода применяется библиотека Comlink.
Main Thread должен отвечать исключительно за пользовательский интерфейс и немедленную реакцию на действия пользователя. Любая задача парсинга (CSV/Excel/GeoJSON), криптографии, сжатия медиа или векторного поиска должна немедленно делегироваться в фоновый Web Worker. Использование Comlink превращает вызов удаленного потока в привычный асинхронный вызов функции await workerApi.processData(...).
Воркер исполняется в полностью изолированном контексте (DedicatedWorkerGlobalScope). Он не имеет прямого доступа к объектам window, document, DOM-нодам, localStorage или контексту React.
┌─────────────────────────────────────────────────────────────┐
│ MAIN THREAD (Бережем FPS и мгновенный отклик!): │
│ React 19 Reconciler │ User Events │ CSS Layout │ Paint UI │
└──────────────────────────────┬──────────────────────────────┘
│ RPC-вызов (Comlink.wrap)
▼ PostMessage / Transferable ArrayBuffer
┌─────────────────────────────────────────────────────────────┐
│ BACKGROUND WEB WORKER THREAD (Фоновый поток ОС): │
│ • Парсинг CSV/Excel на 500 000 строк │
│ • Клиентская криптография (Argon2 / SHA-256 / RSA) │
│ • Обработка изображений через OffscreenCanvas │
│ • Полнотекстовый и векторный поиск по 1 000 000 записей │
└─────────────────────────────────────────────────────────────┘Связь между потоками осуществляется через сериализацию сообщений. По умолчанию браузер использует алгоритм Structured Clone, глубоко копирующий передаваемый объект. При передаче массивов на сотни мегабайт затраты CPU на копирование в памяти могут заблокировать Main Thread не меньше, чем сами вычисления!
Для достижения нулевых накладных расходов применяются:
Определение участков кода, выполняющихся дольше 50 мс, через Performance Profiler (Long Tasks).
Написание типизированного файла worker.ts с экспортом сервисного контракта через Comlink.expose.
Чтение файла в ArrayBuffer и передача с отчуждением владения через Comlink.transfer.
Подключение через хук useWorker, получение результата через Promise и гарантированный terminate() при unmount.
Что происходит с исходным объектом ArrayBuffer в главном потоке после его передачи в Web Worker через механизм Transferable Objects?
OffscreenCanvas параллельно масштабирует их до WebP 1920x1080 и сжимает, экономя гигабайты сетевого трафика.Определим интерфейс сервиса и опубликуем его через Comlink.expose:
import * as Comlink from 'comlink';
export interface ProcessedRow {
id: string;
timestamp: number;
value: number;
category: string;
}
export interface ProcessingReport {
totalRows: number;
validRows: number;
totalSum: number;
averageValue: number;
executionTimeMs: number;
}
export interface DataProcessorApi {
processHeavyDataset(
buffer: ArrayBuffer,
delimiter: string,
onProgress?: (percent: number) => void
): Promise<ProcessingReport>;
}
const dataProcessor: DataProcessorApi = {
async processHeavyDataset(buffer, delimiter, onProgress) {
const startTime = performance.now();
const decoder = new TextDecoder('utf-8');
const csvText = decoder.decode(buffer);
const lines = csvText.split('\n');
const totalLines = lines.length;
let validCount = 0;
let sum = 0;
const step = Math.max(1, Math.floor(totalLines / 100));
for (let i = 1; i < totalLines; i++) {
const line = lines[i]?.trim();
if (!line) continue;
const columns = line.split(delimiter);
const value = parseFloat(columns[2] ?? '0');
if (!Number.isNaN(value)) {
validCount++;
sum += value;
}
// Передаем прогресс каждые 1% строк через Comlink.proxy
if (i % step === 0 && onProgress) {
onProgress(Math.round((i / totalLines) * 100));
}
}
const executionTimeMs = performance.now() - startTime;
return {
totalRows: totalLines - 1,
validRows: validCount,
totalSum: sum,
averageValue: validCount > 0 ? sum / validCount : 0,
executionTimeMs,
};
},
};
Comlink.expose(dataProcessor);Хук изолирует создание и уничтожение системного потока, предоставляя типизированный RPC-интерфейс:
'use client';
import * as React from 'react';
import * as Comlink from 'comlink';
export interface UseWorkerReturn<T> {
api: Comlink.Remote<T> | null;
isLoading: boolean;
error: Error | null;
terminate: () => void;
}
export function useWorker<T>(
workerFactory: () => Worker,
deps: React.DependencyList = []
): UseWorkerReturn<T> {
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
const workerRef = React.useRef<Worker | null>(null);
const apiRef = React.useRef<Comlink.Remote<T> | null>(null);
const terminate = React.useCallback(() => {
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;
apiRef.current = null;
}
}, []);
React.useEffect(() => {
setIsLoading(true);
setError(null);
try {
const worker = workerFactory();
workerRef.current = worker;
const remoteApi = Comlink.wrap<T>(worker);
apiRef.current = remoteApi;
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
}
return () => {
terminate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return {
api: apiRef.current,
isLoading,
error,
terminate,
};
}Создание экземпляра Dedicated Worker через синтаксис new Worker(..., { type: 'module' }) и связывание через Comlink.
Чтение файла в ArrayBuffer и передача с отчуждением владения через Comlink.transfer(buf, [buf]).
Исполнение тяжелого алгоритма на отдельном ядре CPU. Main Thread имеет 0% нагрузки и удерживает 120 FPS.
Разрешение промиса в компоненте, обновление стейта и принудительное завершение потока при unmount.
Почему при передаче функции обратного вызова (например, onProgress) в Web Worker через Comlink обязательна обертка Comlink.proxy(fn)?
Ниже представлен компонент парсинга многомегабайтных таблиц данных с индикатором прогресса и нулевой нагрузкой на главный поток:
'use client';
import * as React from 'react';
import * as Comlink from 'comlink';
import { useWorker } from '@/shared/hooks/useWorker';
import type { DataProcessorApi, ProcessingReport } from '@/shared/workers/dataProcessor.worker';
export function HeavyCsvParser() {
const [progress, setProgress] = React.useState<number>(0);
const [isProcessing, setIsProcessing] = React.useState(false);
const [report, setReport] = React.useState<ProcessingReport | null>(null);
// Фабрика воркера со стандартным синтаксисом Vite / Webpack 5
const { api } = useWorker<DataProcessorApi>(
() =>
new Worker(
new URL('../../shared/workers/dataProcessor.worker.ts', import.meta.url),
{ type: 'module' }
)
);
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !api) return;
setIsProcessing(true);
setProgress(0);
setReport(null);
try {
// 1. Читаем файл в ArrayBuffer
const arrayBuffer = await file.arrayBuffer();
// 2. Создаем proxy-коллбэк для безопасной передачи функции прогресса в Worker
const progressCallback = Comlink.proxy((percent: number) => {
setProgress(percent);
});
// 3. Передаем ArrayBuffer с отчуждением владения (Zero-Copy Transfer)
const result = await api.processHeavyDataset(
Comlink.transfer(arrayBuffer, [arrayBuffer]),
',',
progressCallback
);
setReport(result);
} catch (err) {
console.error('Сбой фонового парсинга:', err);
} finally {
setIsProcessing(false);
}
};
return (
<div className="p-6 rounded-2xl bg-bg-surface border border-border space-y-5">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-bold text-text-primary">Off-Thread CSV Analyzer</h3>
<p className="text-xs text-text-secondary mt-0.5">
Фоновый парсинг через Web Worker + Comlink RPC (INP менее 16 мс)
</p>
</div>
<span className="px-2.5 py-1 rounded-full text-xs font-mono bg-bg-elevated text-accent-violet border border-border">
Main Thread 0% CPU
</span>
</div>
<div className="flex items-center gap-4">
<label className="cursor-pointer px-4 py-2 bg-accent-violet text-white text-sm font-semibold rounded-xl hover:bg-accent-violet/90 transition-colors">
<span>{isProcessing ? 'Обработка...' : 'Выбрать тяжелый CSV файл'}</span>
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
disabled={isProcessing}
className="hidden"
/>
</label>
{isProcessing && (
<span className="text-xs font-mono text-text-secondary animate-pulse">
Прогресс: {progress}%
</span>
)}
</div>
{isProcessing && (
<div className="w-full bg-bg-elevated rounded-full h-2 overflow-hidden">
<div
className="bg-accent-violet h-full transition-all duration-150 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
)}
{report && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-3 border-t border-border">
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Всего строк</div>
<div className="text-lg font-bold font-mono text-text-primary">
{report.totalRows.toLocaleString()}
</div>
</div>
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Валидных записей</div>
<div className="text-lg font-bold font-mono text-emerald-400">
{report.validRows.toLocaleString()}
</div>
</div>
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Среднее значение</div>
<div className="text-lg font-bold font-mono text-text-primary">
{report.averageValue.toFixed(2)}
</div>
</div>
<div className="p-3 rounded-lg bg-bg-elevated/40 border border-border/40">
<div className="text-xs text-text-muted">Время вычислений</div>
<div className="text-lg font-bold font-mono text-accent-violet">
{report.executionTimeMs.toFixed(1)} мс
</div>
</div>
</div>
)}
</div>
);
}Web Workers полностью изолированы от контекста страницы. Попытка отправить через postMessage объект window, HTML-ноду document.getElementById(...) или обычный JS-коллбэк без обертки Comlink.proxy() вызовет фатальное исключение DOMException: The object could not be cloned. Передавать можно только сериализуемые структуры данных или Transferables.
Отправка тяжелого ArrayBuffer через worker.postMessage(buffer) без явного указания списка переноса владения worker.postMessage(buffer, [buffer]) приведет к полному дублированию данных в оперативной памяти и синхронной блокировке Main Thread алгоритмом Structured Clone.
Создание нового экземпляра new Worker(...) при каждом нажатии кнопки или ререндере без вызова worker.terminate() порождает висячие системные потоки. 10–20 неразгруженных воркеров исчерпают лимиты памяти процесса вкладки браузера.
Создание воркера и межпроцессное взаимодействие (IPC) имеют постоянный оверхед (около 1–3 мс). Если вынести в воркер сортировку массива из 50 чисел, код выполнится в 10 раз медленнее, чем при синхронном исполнении в Main Thread. Воркеры оправданы только для вычислений длительностью более 30–50 мс.
ArrayBuffer за время менее 0.5 мс без дублирования памяти.postMessage в типизированные асинхронные вызовы RPC.worker.terminate().