Реализация Web Workers для фоновых вычислений на сайте
JavaScript — однопоточный. Тяжёлая операция в main thread замораживает UI: скролл дёргается, анимации прерываются, кнопки не реагируют. Web Workers решают это, перенося вычисления в отдельный поток операционной системы.
Worker не имеет доступа к DOM, window, document. Общение с main thread — только через сообщения (postMessage/onmessage). Это ограничение — одновременно и защита от race conditions.
Базовая структура
Два файла: основной поток и Worker:
// worker.ts
self.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data
switch (type) {
case 'PROCESS': {
const result = heavyComputation(payload)
self.postMessage({ type: 'RESULT', payload: result })
break
}
}
}
function heavyComputation(data: number[]): number {
// Вычисление, которое заняло бы 500ms в main thread
return data.reduce((sum, n) => sum + Math.sqrt(n), 0)
}
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module',
})
worker.postMessage({ type: 'PROCESS', payload: largeArray })
worker.onmessage = (event: MessageEvent) => {
const { type, payload } = event.data
if (type === 'RESULT') {
console.log('Результат:', payload)
}
}
worker.onerror = (error) => {
console.error('Worker error:', error.message)
}
// Завершить Worker
worker.terminate()
Типизированная обёртка
Работать с сырым postMessage неудобно. Типизированная обёртка решает это:
// worker-bridge.ts
type WorkerMessage<T extends Record<string, unknown>> = {
[K in keyof T]: { type: K; payload: T[K] }
}[keyof T]
interface WorkerRequest {
SORT: { array: number[]; direction: 'asc' | 'desc' }
FILTER: { data: Record<string, unknown>[]; query: string }
PARSE_CSV: { content: string }
}
interface WorkerResponse {
SORT_DONE: number[]
FILTER_DONE: Record<string, unknown>[]
PARSE_CSV_DONE: Record<string, string>[]
ERROR: { message: string }
}
class TypedWorker {
private worker: Worker
private pending = new Map<string, { resolve: Function; reject: Function }>()
private seq = 0
constructor(workerUrl: URL) {
this.worker = new Worker(workerUrl, { type: 'module' })
this.worker.onmessage = ({ data }) => {
const { id, type, payload } = data
const handler = this.pending.get(id)
if (!handler) return
this.pending.delete(id)
if (type === 'ERROR') {
handler.reject(new Error(payload.message))
} else {
handler.resolve(payload)
}
}
}
send<K extends keyof WorkerRequest>(
type: K,
payload: WorkerRequest[K]
): Promise<WorkerResponse[`${K}_DONE` & keyof WorkerResponse]> {
return new Promise((resolve, reject) => {
const id = String(++this.seq)
this.pending.set(id, { resolve, reject })
this.worker.postMessage({ id, type, payload })
})
}
terminate(): void {
this.worker.terminate()
}
}
Передача больших данных — Transferable Objects
postMessage копирует данные по умолчанию. Для больших ArrayBuffer это дорого. Transferable Objects передаются по ссылке (owner transfer), без копирования:
// Создаём буфер
const buffer = new ArrayBuffer(1024 * 1024 * 10) // 10 MB
const view = new Float32Array(buffer)
// ... заполняем данными
// Передаём без копирования — после этого buffer в main thread недоступен
worker.postMessage({ type: 'PROCESS', payload: buffer }, [buffer])
// В Worker
self.onmessage = (event: MessageEvent) => {
const buffer = event.data.payload as ArrayBuffer
const view = new Float32Array(buffer)
// обрабатываем...
// Возвращаем обратно
self.postMessage({ type: 'DONE', payload: buffer }, [buffer])
}
Transferable: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, ReadableStream, WritableStream.
OffscreenCanvas — рендеринг в Worker
// main.ts
const canvas = document.getElementById('chart') as HTMLCanvasElement
const offscreen = canvas.transferControlToOffscreen()
worker.postMessage({ type: 'INIT_CANVAS', canvas: offscreen }, [offscreen])
worker.postMessage({ type: 'RENDER', data: chartData })
// chart-worker.ts
let ctx: OffscreenCanvasRenderingContext2D
self.onmessage = (event: MessageEvent) => {
const { type, canvas, data } = event.data
if (type === 'INIT_CANVAS') {
ctx = canvas.getContext('2d')!
return
}
if (type === 'RENDER') {
renderChart(ctx, data)
}
}
Пул Worker-ов
Для параллельной обработки нескольких задач:
class WorkerPool {
private workers: Worker[] = []
private queue: Array<{ resolve: Function; reject: Function; message: unknown }> = []
private idle: Worker[] = []
constructor(workerUrl: URL, poolSize = navigator.hardwareConcurrency || 4) {
for (let i = 0; i < poolSize; i++) {
const worker = new Worker(workerUrl, { type: 'module' })
worker.onmessage = (event) => this.onWorkerMessage(worker, event)
worker.onerror = (error) => this.onWorkerError(worker, error)
this.workers.push(worker)
this.idle.push(worker)
}
}
execute(message: unknown): Promise<unknown> {
return new Promise((resolve, reject) => {
const task = { resolve, reject, message }
const worker = this.idle.pop()
if (worker) {
this.dispatch(worker, task)
} else {
this.queue.push(task)
}
})
}
private dispatch(worker: Worker, task: { resolve: Function; reject: Function; message: unknown }): void {
(worker as any).__resolve = task.resolve;
(worker as any).__reject = task.reject;
worker.postMessage(task.message)
}
private onWorkerMessage(worker: Worker, event: MessageEvent): void {
(worker as any).__resolve?.(event.data)
this.scheduleNext(worker)
}
private onWorkerError(worker: Worker, error: ErrorEvent): void {
(worker as any).__reject?.(new Error(error.message))
this.scheduleNext(worker)
}
private scheduleNext(worker: Worker): void {
const next = this.queue.shift()
if (next) {
this.dispatch(worker, next)
} else {
this.idle.push(worker)
}
}
terminate(): void {
this.workers.forEach((w) => w.terminate())
}
}
React-хук
function useWorker<TInput, TOutput>(workerUrl: URL) {
const workerRef = useRef<Worker>()
const [result, setResult] = useState<TOutput | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
workerRef.current = new Worker(workerUrl, { type: 'module' })
workerRef.current.onmessage = ({ data }) => {
setResult(data)
setLoading(false)
}
workerRef.current.onerror = (e) => {
setError(e.message)
setLoading(false)
}
return () => workerRef.current?.terminate()
}, [workerUrl.href])
const run = useCallback((payload: TInput) => {
setLoading(true)
setError(null)
workerRef.current?.postMessage(payload)
}, [])
return { run, result, error, loading }
}
Типичные задачи для Workers
- Парсинг и трансформация больших CSV/JSON (>1MB)
- Шифрование/дешифрование данных
- Рендеринг Canvas-графики и диаграмм
- Обработка изображений (ресайз, фильтры, конвертация)
- Алгоритмы поиска и сортировки на больших массивах
- Компрессия данных (pako, zlib)
- Вычисление хешей (SHA-256, MD5)
- Raytracing, физические симуляции
Срок: 1–2 дня в зависимости от сложности задач и необходимости пула Worker-ов.







