Слогер Создать блог
Разработка

Как не заморозить интерфейс: Promises, Workers и Comlink

Async/await не спасает от тяжёлых вычислений на главном потоке. Разбираемся, как Web Workers и библиотека Comlink помогают сохранить отзывчивость UI.

Вы оборачиваете тяжёлый цикл в async функцию, ставите await перед Promise и надеетесь, что страница останется плавной.

Но вкладка зависает. Скролл не работает. Клики накапливаются в очереди. Спиннер не двигается.

Промисы не подвели — они решают другую задачу.

Promises помогают ждать. Они не переносят вычисления с главного потока. Web Workers — да.

В этой статье мы последовательно разберём:

  1. Почему async всё равно замораживает UI
  2. Как Worker решает проблему через postMessage
  3. Как Comlink, SharedArrayBuffer и пулы воркеров помогают, когда приложение растёт

Сквозной пример — обработка большого массива чисел (пиксели или строки аналитики) без блокировки страницы.

Проблема

Многие разработчики думают:

Если код использует async/await/Promises, UI остаётся отзывчивым.

Это верно только для ожидания (сеть, таймеры, диск). Но не для вычислений на главном потоке.

async function processOnMainThread(numbers) { await Promise.resolve(); // уступает один раз, но потом... let total = 0; for (let i = 0; i < numbers.length; i++) { total += Math.sqrt(numbers[i] * numbers[i] + 1); } return total; // ...этот цикл всё ещё на главном потоке } button.addEventListener('click', async () => { status.textContent = 'Working...'; const numbers = Array.from({ length: 8_000_000 }, (_, i) => i); const total = await processOnMainThread(numbers); status.textContent = `Done: ${total}`; });

Ожидание: статус обновляется, страница кликабельна, затем «Done».

Реальность: UI зависает до конца цикла. await Promise.resolve() лишь отложил заморозку на одну микротаску. Математика осталась на главном потоке.

Почему так происходит

Promises возобновляются на том же потоке

Promise означает «значение позже». Когда он выполняется, .then/await продолжаются как микротаски на том же главном потоке.

ГЛАВНЫЙ ПОТОК синхронный код → await (ждём I/O) → снова синхронный код ЗДЕСЬ

Хорошо для fetch. Плохо для многосекундного CPU-цикла.

У главного потока один стек вызовов

Пока этот стек выполняет ваш цикл, браузер не может нормально обрабатывать клики, обновлять DOM или плавно анимировать.

Воркеры — второй поток JavaScript

Web Worker имеет собственный стек вызовов и цикл событий. Он не может трогать DOM. Общение — через сообщения.

ГЛАВНЫЙ ПОТОК ПОТОК ВОРКЕРА UI, DOM, клики тяжёлые вычисления │ postMessage(data) │ └──────────────────────────────────► │ │ │ compute │ ◄──────────────────────────────────┘ onmessage(result)
ПодходГде выполняется циклUI во время работы
await + цикл на главномСтек главного потокаЗаморожен
Worker + postMessageСтек воркераОтзывчив

Главный поток всё ещё может использовать Promise, чтобы дождаться ответа от воркера. Этот Promise ждёт сообщения. Тяжёлая работа уже выполняется в другом месте.

Как диагностировать (до того, как исправить)

Спросите о каждой строке: ожидание или вычисление?

await Promise.resolve(); // ОЖИДАНИЕ (дешёво) for (...) { ... } // ВЫЧИСЛЕНИЕ на главном (дорого)

Если дорогая часть — вычисление, один Promise не поможет.

В Chrome DevTools на вкладке Performance запишите клик. Длинная задача на главном потоке, совпадающая с вашим циклом, — явный признак.

Лучшее решение: перенести цикл в Worker

Используйте Promise для ожидания. Используйте Web Worker для тяжёлых вычислений.

Паттерн:

  1. UI остаётся на главном потоке
  2. Цикл помещается в worker.js
  3. Данные отправляются через postMessage
  4. Ответ оборачивается в Promise, чтобы UI-код мог использовать await

worker.js

self.onmessage = (event) => { const numbers = event.data; let total = 0; for (let i = 0; i < numbers.length; i++) { total += Math.sqrt(numbers[i] * numbers[i] + 1); } self.postMessage(total); };

main.js

function processInWorker(numbers) { return new Promise((resolve, reject) => { const worker = new Worker(new URL('./worker.js', import.meta.url)); worker.onmessage = (event) => { resolve(event.data); worker.terminate(); }; worker.onerror = (error) => { reject(error); worker.terminate(); }; worker.postMessage(numbers); }); } button.addEventListener('click', async () => { status.textContent = 'Working...'; const numbers = Array.from({ length: 8_000_000 }, (_, i) => i); const total = await processInWorker(numbers); status.textContent = `Done: ${total}`; });

Теперь UI не зависает. Цикл выполняется в отдельном потоке, а главный поток спокойно обрабатывает клики и обновляет статус.

Comlink: вызываем методы воркера как обычные функции

Библиотека Comlink (от Google) убирает шаблонный код postMessage/onmessage. Вы просто вызываете функции воркера через await.

Пример с Comlink:

// worker.js importScripts('https://unpkg.com/comlink/umd/comlink.js'); const api = { calculate(numbers) { let total = 0; for (let i = 0; i < numbers.length; i++) { total += Math.sqrt(numbers[i] * numbers[i] + 1); } return total; } }; Comlink.expose(api); // main.js import * as Comlink from 'https://unpkg.com/comlink/umd/comlink.js'; async function run() { const worker = new Worker('./worker.js'); const api = Comlink.wrap(worker); const total = await api.calculate(largeArray); console.log(total); worker.terminate(); }

Comlink автоматически обрабатывает передачу данных и промисы. Это особенно удобно, когда у воркера много методов.

SharedArrayBuffer: разделяемая память без копирования

Для очень больших массивов (например, видеокадры) копирование данных через postMessage может быть затратным. SharedArrayBuffer позволяет разделить память между главным потоком и воркером без копирования.

Но нужны дополнительные заголовки COOP и COEP для безопасности. И доступен он не во всех браузерах (например, не в Safari).

Пример с Comlink + SharedArrayBuffer:

// main.js const buffer = new SharedArrayBuffer(8_000_000 * 4); // 32-битные числа const view = new Float32Array(buffer); // заполняем view данными... const worker = new Worker('./worker.js'); const api = Comlink.wrap(worker); const result = await api.process(buffer, view.length); console.log(result);

В воркере:

const api = { process(buffer, length) { const view = new Float32Array(buffer); let total = 0; for (let i = 0; i < length; i++) { total += Math.sqrt(view[i] * view[i] + 1); } return total; } }; Comlink.expose(api);

Такой подход даёт максимальную производительность, но требует настройки сервера и поддержки браузера.

Пулы воркеров: много задач — много потоков

Когда задач много (например, пакетная обработка изображений), создавать по воркеру на каждую задачу неэффективно. Используйте пул воркеров — фиксированное количество воркеров, которые выполняют задачи из очереди.

В браузере популярна библиотека workerpool. На Node.js — Piscina (на базе worker_threads).

Пример с workerpool:

import workerpool from 'workerpool'; const pool = workerpool.pool(); async function run() { const numbers = Array.from({ length: 8_000_000 }, (_, i) => i); const result = await pool.exec(heavyFunction, [numbers]); console.log(result); pool.terminate(); } function heavyFunction(data) { let total = 0; for (let i = 0; i < data.length; i++) { total += Math.sqrt(data[i] * data[i] + 1); } return total; }

Пул автоматически распределяет задачи по воркерам и переиспользует их.

Заключение

Promises — отличный инструмент для ожидания асинхронных операций, но они не переносят вычисления в другой поток. Для тяжёлых CPU-задач используйте Web Workers. Comlink упрощает взаимодействие, SharedArrayBuffer ускоряет передачу данных, а пулы воркеров помогают масштабировать обработку.

Главный принцип: ждите с Promises, вычисляйте в Workers.

По материалам: dev. Текст переработан редакцией Слогера.

← На главную

Рекламное место — Конец поста
Реклама · Слогер

Комментарии (0)

Войдите, чтобы комментировать.

Пока нет комментариев. Будьте первым.