Worker Kit
Типобезопасные composables для Web Worker'ов во Vue 3 — useWorker(), пул воркеров и реактивный useWorkerComputed(), с типами входа/выхода, выводимыми прямо из файла воркера. Никаких runtime-зависимостей, кроме Vue.
Возможности
useWorker()— типобезопасный composable, оборачивающий один Web Worker; типы входа/выхода выводятся изtypeof import('./x.worker'), без ручных дженериков ни с одной из сторонcreateWorkerPool()/useWorkerPool()— лениво растущий пул (размер по умолчанию:navigator.hardwareConcurrency) для множества мелких независимых задач;pool.run()/pool.map()с ограниченной параллельностьюuseWorkerComputed()—computed(), пересчитывающийся внутри воркера при каждом изменении реактивного источника; устаревшие/вытесненные запуски автоматически отбрасываются через номер поколенияuseSharedWorker()— переиспользует одинSharedWorkerво всех вкладках/окнах одного источника вместо отдельного воркера на каждую вкладкуdefineWorkerHandler()— вспомогательная функция на стороне воркера, автоматически настраивающая протокол сообщений run/cancel; вы пишете только саму функцию-обработчик- Transferables — передача с нулевым копированием в воркер (
RunOptions.transfer) и обратно (ctx.transfer()) для больших буферов вроде изображений или кадровOffscreenCanvas - Стриминг / частичные результаты —
ctx.reportChunk()отправляет промежуточные результаты, пока длительная задача ещё выполняется, собираются реактивно вchunks - Отмена — поддержка
AbortSignal, кооперативная черезctx.signal;run()отклоняется немедленно независимо от того, что делает воркер - Повтор с задержкой (backoff) — опции
retries/retryDelayавтоматически повторяют неотменённые сбои с постоянной или зависящей от попытки задержкой - Мемоизация / кеш результатов — опциональный LRU-кеш по ключу
JSON.stringify(input), полностью пропускает обращение к воркеру для повторяющегося входа - Прогрев (warmup) — заранее создайте воркер (или весь пул), чтобы устранить задержку холодного старта ~50-200 мс перед первой реальной задачей
- Управление жизненным циклом воркера — завершение по таймауту простоя, авто-завершение на основе scope через
onScopeDispose(без утечек при SPA-навигации), ленивое создание воркеров пула - Панель Devtools —
<WorkerActivityPanel>изvue-worker-kit/devtoolsпоказывает количество занятых/свободных воркеров, длину очереди, среднее время задачи и последние ошибки — без зависимости от@vue/devtools-api - Структурированная обработка ошибок — брошенная в воркере ошибка становится
WorkerErrorс исходным in-worker стеком, сохранённым в.workerStack;DataCloneErrorдля значений, которые не проходят structured-clone;WorkerUnavailableErrorвместо сырогоReferenceErrorпри SSR - Сквозной вывод типов — тип входа/выхода функции воркера выводится прямо из файла воркера через
typeof import(...), а не дублируется вручную с обеих сторон - Ноль runtime-зависимостей, кроме Vue
Проблема
Существующие Vue-обёртки над Web Worker'ами (vue-worker, vue-web-workers и подобные) — это плагины эпохи Vue 2: без типов, без Composition API, без пула, без transferables, одноразовые воркеры, собранные сериализацией функции в строку. Comlink даёт надёжный RPC-протокол, но типизировать его приходится вручную (Comlink.wrap<MyAPI>()), без реактивности Vue и без интеграции с жизненным циклом компонента.
Главная отличительная идея этого пакета: сквозная типизация без дублирования дженериков. Тип входа/выхода функции воркера выводится прямо из файла воркера через typeof import(...), а не прописывается вручную с обеих сторон.
async/await против реального потока
Стоит проговорить явно, потому что легко решить, что async/await уже решает эту задачу: async/await не выносит работу с JS-потока. JavaScript (вне воркеров) всегда выполняется в один поток, сколько бы async/await вы туда ни добавляли.
Есть два принципиально разных случая, которые называют «асинхронностью»:
- Ожидание ввода-вывода —
fetch,setTimeout, любой промис, опирающийся на API браузера/ОС. Само ожидание происходит вне JS (в сетевом стеке, в таймере ОС), поэтому основной поток действительно свободен во времяawait. Воркер здесь не нужен никогда. - Вычисление, ограниченное CPU — ваш собственный цикл, сортировка, парсинг. Обёртывание в
async functionничего не меняет: цикл по-прежнему выполняется синхронно, в том же потоке, который также пытается отрисовывать ваш UI и обрабатывать клики. Единственный способ сохранить отзывчивость UI без воркера — вручную порезать цикл на куски и уступать управление (await new Promise(r => setTimeout(r))) между ними — это ровно то, для чего внутри воркера служит паттернctx.reportProgress/ctx.signalуdefineWorkerHandler, но на основном потоке это ничего не даёт: это всё тот же поток, просто более мелкие куски той же работы чередуются с рендерингом.
Worker — это по-настоящему отдельный поток ОС. В этом и заключается структурное отличие от async/await:
- Основной поток на 100% свободен на всё время вычисления — не нужно вручную резать на куски и уступать управление только ради того, чтобы UI не завис (резать всё равно придётся, если нужны отчёт о прогрессе или отмена, но это опционально, а не обязательно для отзывчивости).
- Это не делает работу автоматически быстрее по затраченному времени — у
postMessage/структурного клонирования и запуска воркера есть реальная цена, и для короткого вычисления обычный запуск на основном потоке вполне может завершиться раньше. Смысл воркера не в сырой скорости, а в том, что работа больше не конкурирует с UI за один и тот же поток.createWorkerPool()— единственное место, где вы действительно получаете реальный прирост скорости от параллелизма: несколько воркеров по-настоящему считают на разных ядрах CPU одновременно.
Как работает вывод типов
typeof import('./heavy-sort.worker') — это выражение только для типов: TypeScript стирает его на этапе компиляции. Оно не импортирует код файла воркера в основной бандл — воркер всегда загружается только через new URL(..., import.meta.url), как отдельный чанк. defineWorkerHandler() возвращает фантомно типизированный маркер (поля __input/__output, которых никогда не существует в рантайме); useWorker/createWorkerPool считывают In/Out с этого маркера через условный тип. В итоге сигнатура run() — ровно (input: In, options?: RunOptions) => Promise<Out>, без ручного дженерика для формы данных ни с одной из сторон.