Перейти к содержимому

51. Web Workers

Привет! Яша здесь. JavaScript — однопоточный язык, и тяжёлые вычисления блокируют UI. Web Workers решают эту проблему: они дают нам второй поток. Сегодня разберём как использовать Web Workers в Angular по-взрослому 💪


Главный поток браузера занимается:

  • Рендерингом DOM
  • Обработкой событий
  • Выполнением JavaScript

Если JS занимает более 50ms — браузер начинает «зависать». Примеры тяжёлых задач:

  • Обработка больших массивов данных (>100k элементов)
  • Криптография, хеширование
  • Парсинг/трансформация JSON/CSV
  • Генерация отчётов, PDF
  • Машинное обучение в браузере
Главный поток (UI): [UI] [UI] [Heavy!!!! блок ] [UI] — пользователь видит зависание
Web Worker: [UI] [UI] [────────────────] [UI] — UI не блокируется
↑ Worker считает отдельно

Окно терминала
ng generate web-worker app
# Или для конкретного модуля
ng generate web-worker features/data-processor/data-processor

Angular автоматически создаст два файла:

src/app/app.worker.ts
/// <reference lib="webworker" />
addEventListener('message', ({ data }) => {
// data — то что пришло из главного потока
const result = yourHeavyComputation(data);
postMessage(result); // отправить результат обратно
});
// src/app/app.component.ts — вызов worker
if (typeof Worker !== 'undefined') {
const worker = new Worker(new URL('./app.worker', import.meta.url));
worker.onmessage = ({ data }) => {
console.log('Результат:', data);
};
worker.postMessage({ input: 'start' });
}

// worker.service.ts — абстракция над Workers
import { Injectable, OnDestroy } from '@angular/core';
import { Observable, Subject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class DataWorkerService implements OnDestroy {
private worker: Worker;
private responses$ = new Subject<any>();
constructor() {
this.worker = new Worker(
new URL('./data.worker', import.meta.url),
{ type: 'module' }
);
this.worker.onmessage = ({ data }) => {
this.responses$.next(data);
};
this.worker.onerror = (err) => {
this.responses$.error(err);
};
}
// Отправить задачу и получить Observable с результатом
process<T>(command: string, payload: unknown): Observable<T> {
return new Observable<T>(observer => {
const subscription = this.responses$.subscribe({
next: (data) => {
if (data.command === command) {
observer.next(data.result as T);
observer.complete();
}
},
error: (err) => observer.error(err),
});
this.worker.postMessage({ command, payload });
return () => subscription.unsubscribe();
});
}
ngOnDestroy(): void {
this.worker.terminate();
this.responses$.complete();
}
}
data.worker.ts
/// <reference lib="webworker" />
addEventListener('message', ({ data }) => {
const { command, payload } = data;
switch (command) {
case 'sortLargeArray':
const sorted = [...payload].sort((a, b) => a - b);
postMessage({ command, result: sorted });
break;
case 'calculatePrimes':
const primes = findPrimes(payload.limit);
postMessage({ command, result: primes });
break;
case 'processCSV':
const rows = parseAndTransformCSV(payload.csv);
postMessage({ command, result: rows });
break;
}
});
function findPrimes(limit: number): number[] {
const sieve = new Uint8Array(limit + 1).fill(1);
sieve[0] = sieve[1] = 0;
for (let i = 2; i * i <= limit; i++) {
if (sieve[i]) {
for (let j = i * i; j <= limit; j += i) sieve[j] = 0;
}
}
return Array.from(sieve.entries()).filter(([, v]) => v).map(([i]) => i);
}

Comlink от Google — устраняет boilerplate:

Окно терминала
npm install comlink
heavy-computation.worker.ts
import { expose } from 'comlink';
// Экспортируем класс — Comlink создаст прокси
const api = {
async sortArray(arr: number[]): Promise<number[]> {
return [...arr].sort((a, b) => a - b);
},
async findPrimes(limit: number): Promise<number[]> {
const sieve = new Uint8Array(limit + 1).fill(1);
sieve[0] = sieve[1] = 0;
for (let i = 2; i * i <= limit; i++) {
if (sieve[i]) {
for (let j = i * i; j <= limit; j += i) sieve[j] = 0;
}
}
return Array.from(sieve.entries()).filter(([, v]) => v).map(([i]) => i);
},
async hashData(data: string): Promise<string> {
const buffer = new TextEncoder().encode(data);
const hash = await crypto.subtle.digest('SHA-256', buffer);
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
};
expose(api);
export type HeavyComputationWorker = typeof api;
computation.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { wrap, Remote } from 'comlink';
import type { HeavyComputationWorker } from './heavy-computation.worker';
@Injectable({ providedIn: 'root' })
export class ComputationService implements OnDestroy {
private worker: Worker;
// Remote<T> — типизированный прокси на worker API
private api: Remote<HeavyComputationWorker>;
constructor() {
this.worker = new Worker(
new URL('./heavy-computation.worker', import.meta.url),
{ type: 'module' }
);
this.api = wrap<HeavyComputationWorker>(this.worker);
}
// Полная типизация — TypeScript знает все методы и их сигнатуры!
async sortArray(arr: number[]): Promise<number[]> {
return this.api.sortArray(arr);
}
async findPrimes(limit: number): Promise<number[]> {
return this.api.findPrimes(limit);
}
async hashData(data: string): Promise<string> {
return this.api.hashData(data);
}
ngOnDestroy(): void {
this.worker.terminate();
}
}

По умолчанию данные копируются между потоками. Для больших данных используем Transferable:

// ❌ Копирование — медленно для больших массивов
worker.postMessage({ data: hugeArrayBuffer });
// ✅ Transfer — передаём владение без копирования (O(1) по времени!)
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
worker.postMessage({ buffer }, [buffer]); // второй аргумент — список transferable
// После transfer — buffer в главном потоке недоступен!
console.log(buffer.byteLength); // 0 — передан worker'у

// Требует COOP/COEP заголовков на сервере
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Embedder-Policy: require-corp
// Главный поток
const sharedBuffer = new SharedArrayBuffer(4); // 4 байта
const sharedArray = new Int32Array(sharedBuffer);
worker.postMessage({ sharedBuffer }); // передаём без копирования
// Читаем результат через Atomics
Atomics.wait(sharedArray, 0, 0); // ждём пока worker запишет
console.log(sharedArray[0]); // читаем результат
// Worker
self.onmessage = ({ data }) => {
const arr = new Int32Array(data.sharedBuffer);
// Долгие вычисления...
Atomics.store(arr, 0, 42); // записываем результат
Atomics.notify(arr, 0); // будим главный поток
};

csv-processor.worker.ts
/// <reference lib="webworker" />
interface CSVRow {
id: number;
amount: number;
category: string;
date: string;
}
interface ProcessResult {
total: number;
byCategory: Record<string, number>;
topItems: CSVRow[];
}
addEventListener('message', ({ data }: MessageEvent<string>) => {
const rows = parseCSV(data);
const result: ProcessResult = {
total: rows.reduce((sum, r) => sum + r.amount, 0),
byCategory: rows.reduce((acc, r) => ({
...acc,
[r.category]: (acc[r.category] || 0) + r.amount
}), {}),
topItems: [...rows].sort((a, b) => b.amount - a.amount).slice(0, 10),
};
postMessage(result);
});
function parseCSV(raw: string): CSVRow[] {
return raw.split('\n').slice(1).map(line => {
const [id, amount, category, date] = line.split(',');
return { id: +id, amount: +amount, category, date };
});
}
// В компоненте
@Component({
template: `
<input type="file" (change)="onFile($event)" accept=".csv">
@if (loading()) {
<p>⏳ Обрабатываю в Web Worker...</p>
}
@if (result()) {
<p>Итого: {{ result()!.total | currency }}</p>
}
`
})
export class CSVComponent {
loading = signal(false);
result = signal<ProcessResult | null>(null);
private worker = new Worker(
new URL('./csv-processor.worker', import.meta.url)
);
constructor() {
this.worker.onmessage = ({ data }) => {
this.result.set(data);
this.loading.set(false);
};
}
async onFile(event: Event): Promise<void> {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
this.loading.set(true);
const text = await file.text();
this.worker.postMessage(text);
}
}

// ❌ НЕ ДОСТУПНО в Workers:
// - DOM (document, window)
// - localStorage/sessionStorage
// - History API
// - Alert/confirm/prompt
// - Angular DI контейнер
// ✅ ДОСТУПНО:
// - fetch API
// - WebSockets
// - IndexedDB
// - crypto API
// - Canvas (OffscreenCanvas)
// - Timers (setTimeout, setInterval)
// - ArrayBuffer, TypedArrays
// - WebAssembly

Сравнение выполнения тяжёлой задачи в main thread vs Web Worker: