51. Web Workers
54. Web Workers в Angular 🔧
Заголовок раздела «54. Web Workers в Angular 🔧»Привет! Яша здесь. JavaScript — однопоточный язык, и тяжёлые вычисления блокируют UI. Web Workers решают эту проблему: они дают нам второй поток. Сегодня разберём как использовать Web Workers в Angular по-взрослому 💪
Зачем нужны Web Workers
Заголовок раздела «Зачем нужны Web Workers»Главный поток браузера занимается:
- Рендерингом DOM
- Обработкой событий
- Выполнением JavaScript
Если JS занимает более 50ms — браузер начинает «зависать». Примеры тяжёлых задач:
- Обработка больших массивов данных (>100k элементов)
- Криптография, хеширование
- Парсинг/трансформация JSON/CSV
- Генерация отчётов, PDF
- Машинное обучение в браузере
Главный поток (UI): [UI] [UI] [Heavy!!!! блок ] [UI] — пользователь видит зависаниеWeb Worker: [UI] [UI] [────────────────] [UI] — UI не блокируется ↑ Worker считает отдельноСоздание Web Worker через Angular CLI
Заголовок раздела «Создание Web Worker через Angular CLI»ng generate web-worker app
# Или для конкретного модуляng generate web-worker features/data-processor/data-processorAngular автоматически создаст два файла:
/// <reference lib="webworker" />
addEventListener('message', ({ data }) => { // data — то что пришло из главного потока const result = yourHeavyComputation(data); postMessage(result); // отправить результат обратно});// src/app/app.component.ts — вызов workerif (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: обёртка для Angular
Заголовок раздела «Worker Service: обёртка для Angular»// worker.service.ts — абстракция над Workersimport { 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(); }}/// <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: типобезопасные Workers
Заголовок раздела «Comlink: типобезопасные Workers»Comlink от Google — устраняет boilerplate:
npm install comlinkimport { 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;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 Objects
Заголовок раздела «Передача данных: Transferable Objects»По умолчанию данные копируются между потоками. Для больших данных используем Transferable:
// ❌ Копирование — медленно для больших массивовworker.postMessage({ data: hugeArrayBuffer });
// ✅ Transfer — передаём владение без копирования (O(1) по времени!)const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MBworker.postMessage({ buffer }, [buffer]); // второй аргумент — список transferable
// После transfer — buffer в главном потоке недоступен!console.log(buffer.byteLength); // 0 — передан worker'уSharedArrayBuffer: общая память
Заголовок раздела «SharedArrayBuffer: общая память»// Требует 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 }); // передаём без копирования
// Читаем результат через AtomicsAtomics.wait(sharedArray, 0, 0); // ждём пока worker запишетconsole.log(sharedArray[0]); // читаем результат
// Workerself.onmessage = ({ data }) => { const arr = new Int32Array(data.sharedBuffer); // Долгие вычисления... Atomics.store(arr, 0, 42); // записываем результат Atomics.notify(arr, 0); // будим главный поток};Практический пример: обработка CSV
Заголовок раздела «Практический пример: обработка CSV»/// <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); }}Ограничения Web Workers
Заголовок раздела «Ограничения Web Workers»// ❌ НЕ ДОСТУПНО в 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// - WebAssemblyPlayground 🎮
Заголовок раздела «Playground 🎮»Сравнение выполнения тяжёлой задачи в main thread vs Web Worker: