Архітектура браузерного 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().