vue-worker-kit: типобезопасные Web Worker'ы для Vue 3 — когда async/await не спасает от подвисания интерфейса

Представьте таблицу на 50 тысяч строк с фильтрами по нескольким колонкам и сортировкой по клику на заголовок. Пользователь щёлкает фильтром — и на полсекунды, а то и на секунду, всё замирает: не крутится спиннер, не реагирует ни одна кнопка, скролл дёргается. Первая мысль почти у каждого: «сделаю пересчёт в async function, пусть будет асинхронным». Пишете async, ставите await перед тяжёлым циклом — и ничего не меняется. Фриз остаётся ровно таким же.
Причина в том, что async/await в браузере не выносит работу на другой поток — вообще никак. JavaScript в браузере (вне воркеров) всегда выполняется в одном-единственном потоке, сколько бы async-функций вы ни написали. Есть два принципиально разных смысла, которые люди вкладывают в слово «асинхронно»:
- Ожидание I/O —
fetch,setTimeout, любой промис, завязанный на браузерный или ОС-уровень API. Здесьawaitдействительно не блокирует поток, потому что сама работа (сетевой запрос, таймер) происходит не в JS, а где-то в сетевом стеке браузера или в операционной системе. Пока идёт такое ожидание, главный поток и правда свободен — может рендерить, реагировать на клики. - CPU-bound вычисление — ваш собственный цикл, сортировка, обход дерева, парсинг. Обёртка в
async functionне меняет для него вообще ничего: цикл как выполнялся синхронно на главном потоке, так и продолжает выполняться на нём же. Единственный способ не заморозить UI без воркера — вручную нарезать вычисление на куски и отдавать управление событийному циклу между ними (await new Promise(r => setTimeout(r))), что решает проблему отзывчивости ценой усложнения бизнес-логики и не решает главного: вся эта работа по-прежнему выполняется на том же самом потоке, что и рендер, просто мелкими урывками.
Единственный способ реально освободить главный поток — реальный отдельный поток, то есть Worker. И тут начинается вторая, отдельная проблема: нативный API воркеров — это голый postMessage/onmessage с ручной маршрутизацией сообщений по id, без единого пикселя типизации, без интеграции с жизненным циклом Vue-компонента, без пула, без прогресса и отмены из коробки. Каждый, кто хоть раз пробовал завести воркер в реальном проекте, писал этот код заново: генератор id для сопоставления запроса с ответом, Map для хранящихся промисов, ручной terminate() в onUnmounted, и так по кругу в каждом новом проекте.
Comlink от Google решает часть этой боли — у него приятный RPC-протокол поверх воркера. Но типизация там ручная (Comlink.wrap<MyAPI>(), дженерик пишется на каждой стороне отдельно), нет ни встроенной Vue-реактивности, ни жизненного цикла, ни пула из коробки. А по-настоящему Vue-специфичные пакеты под воркеры (vue-worker, vue-web-workers) не обновлялись с 2017 и 2020 года соответственно — оба рассчитаны на Vue 2 с Options API, ни один даже не упоминает TypeScript.
Написал vue-worker-kit — закрывает всё это одним пакетом. Флагманская идея, ради которой всё затевалось: вход/выход воркер-функции выводится из типа самого файла воркера, а не прописывается вручную дженериком с обеих сторон. Плюс — worker pool с реальным ускорением на нескольких ядрах, реактивный useWorkerComputed(), отмена через стандартный AbortSignal, transferable-объекты в обе стороны без единой лишней копии памяти, SSR-safety из коробки и devtools-панель загрузки воркеров. Ядро — ~2.3 KB gzip, единственная peer-зависимость — Vue 3.4+. Ниже — подробный разбор каждой фичи с примерами, парой историй про баги, найденные по дороге, и три сценария из реальных проектов.
Установка
bash
npm install vue-worker-kit
Поставляется как tree-shakeable ESM и CommonJS одновременно, с полными TypeScript-типами для всех опций, контекста воркера и возвращаемых значений. Zero runtime dependencies — только vue в peer, ни у ядра, ни у одной из опциональных частей. Каждая часть пакета — отдельная точка входа в exports, которая физически не попадает в бандл, пока вы её явно не импортировали:
vue-worker-kit — useWorker(), типы, ошибки
vue-worker-kit/worker — defineWorkerHandler() — для файлов *.worker.ts
vue-worker-kit/pool — createWorkerPool(), useWorkerPool()
vue-worker-kit/computed — useWorkerComputed()
vue-worker-kit/devtools — createWorkerActivityMonitor(), <WorkerActivityPanel>
Отдельно стоит сказать про сборку: никаких worker-loader, worker-plugin или прочих костылей эпохи Webpack не требуется. Используется нативный new URL('./x.worker.ts', import.meta.url) в связке с { type: 'module' } — синтаксис, который Vite (а значит, и Nuxt 3/4) распознаёт статически и собирает воркер отдельным чанком автоматически, без единой строчки конфигурации. Если вы всё ещё сидите на классическом Webpack через старый Vue CLI — там придётся добавить worker-plugin или аналог, но это ограничение бандлера, а не пакета.
Флагманская фича: типы выводятся из файла воркера
Начнём со стороны воркера. defineWorkerHandler() оборачивает функцию-обработчик и сам подключает протокол postMessage — вызывающий код не пишет вручную ни строчки маршрутизации сообщений:
ts
// heavy-sort.worker.ts
import { defineWorkerHandler } from 'vue-worker-kit/worker'
export default defineWorkerHandler(async (data: number[], ctx) => {
for (let i = 0; i < data.length; i++) {
if (ctx.signal.aborted) throw ctx.signal.reason
if (i % 10_000 === 0) ctx.reportProgress(i / data.length)
}
return data.sort((a, b) => a - b)
})
А теперь — главный поток, там, где обычно и начинаются проблемы с типами:
ts
import { useWorker } from 'vue-worker-kit'
const { run, isRunning, progress, error, cancel } = useWorker<typeof import('./heavy-sort.worker')>(
() => new Worker(new URL('./heavy-sort.worker.ts', import.meta.url), { type: 'module' }),
)
const sorted = await run(hugeArray) // sorted: number[] — без единой ручной аннотации
Ключевая строчка — typeof import('./heavy-sort.worker'). Это type-only конструкция: TypeScript стирает её целиком при компиляции, никакого рантайм-импорта файла воркера в основной бандл при этом не происходит — сам воркер грузится только через new URL(...), отдельным чанком, ровно так, как задумано. Стоит проговорить это явно в любом описании пакета, иначе выглядит так, будто он тянет код воркера в главный поток, а это не так.
Механика внутри простая, если один раз в неё вникнуть. defineWorkerHandler() возвращает объект-маркер с «призрачными» полями __input/__output, которых в рантайме не существует вообще — они существуют только на уровне типов:
ts
interface WorkerHandlerModule<In = unknown, Out = unknown> {
readonly __input?: In
readonly __output?: Out
}
useWorker<T> вытаскивает In/Out из этого маркера через условный тип, применённый именно к типу модуля целиком (typeof import(...), то есть { default: WorkerHandlerModule<In, Out> }), а не к типу самого default export'а напрямую — это важная деталь, которая иначе легко ломается при рефакторинге. Итог: если внутри воркера структура входных данных поменялась, а на вызывающей стороне — нет, вы получите ошибку компиляции прямо на строке run(...), а не рантайм-сюрприз через полгода в проде, когда об этом контракте между двумя файлами уже никто не помнит.
Отдельно стоит проверить, что defineWorkerHandler реально ничего не делает, если файл воркера вдруг импортируется не туда — например, случайно попал в основной бандл вместо отдельного чанка. Это встроенная защита: подключение self.onmessage происходит только тогда, когда self instanceof WorkerGlobalScope реально истинно; в любом другом контексте функция просто возвращает пустой объект-маркер без единого побочного эффекта.
Как это устроено под капотом
Протокол между главным потоком и воркером — пять типов сообщений: run, cancel (главный поток → воркер) и result, error, progress (воркер → главный поток), каждое с числовым id, по которому главный поток сопоставляет ответ с конкретным вызовом run(). Ничего экзотического — то же самое любой опытный разработчик писал бы руками сам, просто один раз, аккуратно, с обработкой всех крайних случаев, и один раз протестированным.
Что важно: главный поток при вызове signal.abort() реджектит промис немедленно, не дожидаясь ответа от воркера — гонка выигрывается через Promise.race-подобную конструкцию между слушателем на AbortSignal и реальным ответом. Воркеру при этом всё равно уходит cancel-сообщение, и его ctx.signal абортится — но если хендлер физически не проверяет ctx.signal.aborted внутри своего цикла, работа в воркере продолжится в фоне, просто её результат уже никого не интересует на главном потоке. Это и есть «кооперативная отмена» из спецификации: сигнал реально прокидывается, но реакция на него — забота хендлера, а не пакета.
Прогресс: троттлинг и один найденный по дороге баг
ctx.reportProgress(value) внутри воркера шлёт число 0..1 на главный поток, троттлится по времени примерно до 20 сообщений в секунду — чтобы частый вызов внутри плотного цикла не забивал канал postMessage сотнями сообщений в секунду.
Готовя демо к этому пакету, столкнулся с забавным и неочевидным багом: индикатор прогресса иногда «застревал» на 80–95% и никогда не доходил до 100%, хотя вычисление уже реально завершилось и run() успешно резолвился. Причина — прямое следствие троттлинга: если хендлер репортит прогресс только на периодических контрольных точках (скажем, каждые 5% от общего объёма работы), то самый последний чекпоинт перед завершением цикла вполне может попасть в окно троттлинга предыдущего вызова и молча потеряться — а сам хендлер ведь никак не обязан явно вызывать reportProgress(1) прямо перед return.
Починил на уровне протокола, а не переложил проблему на разработчиков хендлеров: перед отправкой финального result-сообщения пакет всегда дополнительно шлёт нетроттленное progress: 1, независимо от того, что там было в последнем вызове ctx.reportProgress(). Прогресс-бар в интерфейсе теперь гарантированно доезжает до конца при любом хендлере — руками об этом думать не нужно.
Отмена: кооперативная и жёсткая
Отмена — через стандартный AbortSignal, без изобретения собственного API:
ts
const controller = new AbortController()
const promise = run(hugeArray, { signal: controller.signal })
controller.abort() // promise реджектится AbortError немедленно
Если свой signal не передавать — useWorker() создаёт его сам внутри, а метод cancel(), возвращаемый композаблом, абортит именно этот внутренний контроллер. По умолчанию воркер при отмене продолжает жить и работать в фоне — терминировать поток и поднимать его заново дороже, чем просто перестать интересоваться результатом конкретного вызова. Но для сценариев, где важно гарантированно и немедленно остановить именно вычисление (например, пользователь ушёл со страницы с очень тяжёлой задачей, и вы хотите освободить память и CPU прямо сейчас, а не когда-нибудь), есть явный флаг:
ts
useWorker<typeof import('./heavy-sort.worker')>(factory, { hardCancelOnAbort: true })
При hardCancelOnAbort: true вызов abort() реально терминирует воркер немедленно и прозрачно поднимает новый на следующий run() — компонент, использующий композабл, не замечает разницы в API, только в поведении под капотом.
Жизненный цикл воркера: idle timeout, ретраи, автотерминация
Три опции useWorker(), которые редко замечают в документации других пакетов, но которые реально важны в долгоживущем приложении:
ts
useWorker<typeof import('./heavy-sort.worker')>(factory, {
idleTimeout: 30_000, // по умолчанию: 30 секунд простоя — терминация; false — никогда
retries: 1, // автоповтор при реджекте, кроме отмены по signal
})
idleTimeout решает конкретную проблему утечки памяти: воркер, который вы создали для одной тяжёлой операции, не должен висеть в памяти вечно, если больше не используется, но и не должен терминироваться после каждого вызова, если пользователь щёлкает кнопку раз в секунду — тогда накладные расходы на пересоздание потока перекроют всю выгоду. Компромисс — таймер простоя: если после последнего run() прошло больше idleTimeout, воркер терминируется сам, а следующий вызов run() прозрачно поднимает новый (с небольшой задержкой на первый вызов после простоя — это ожидаемо и стоит явно закладывать в UX).
retries — автоматический повтор при реджекте, но с одной важной оговоркой: отмена через AbortSignal никогда не попадает под ретраи, даже если retries больше нуля. Пользователь явно попросил остановить операцию — повторять её вопреки этому решению было бы просто неправильным поведением, независимо от значения опции.
Автотерминация по scope — если useWorker()/useWorkerPool() вызван внутри активного Vue effect scope (то есть обычным образом в setup()), воркер (или весь пул) автоматически терминируется через onScopeDispose() при размонтировании компонента. Без этого механизма типична утечка при SPA-навигации: компонент ушёл, а поднятый им воркер продолжает висеть в памяти браузера до перезагрузки вкладки.
Transferable-объекты — в обе стороны без копирования
ArrayBuffer, MessagePort, ImageBitmap и всё остальное, что подпадает под спецификацию Transferable, можно передать в воркер без структурного клонирования — сразу «переместить», без единой лишней копии в памяти:
ts
const buffer = new ArrayBuffer(10 * 1024 * 1024)
const result = await run(buffer, { transfer: [buffer] })
// buffer.byteLength === 0 сразу же после вызова — буфер detached, а не скопирован
Обратное направление — вернуть большой буфер из воркера обратно на главный поток без копирования — во многих подобных пакетах вообще не поддерживается, и в первой версии этого пакета тоже не было. Добавил симметричный вызов внутри хендлера, ctx.transfer():
ts
// resize.worker.ts
export default defineWorkerHandler((input: ResizeInput, ctx) => {
const output = resize(input) // производит новый ArrayBuffer
ctx.transfer(output) // уйдёт на главный поток zero-copy, а не через structured clone
return output
})
ctx.transfer() можно вызвать несколько раз подряд — каждый вызов добавляет объекты в общий список transferables для этого конкретного ответа, и передаваемый объект вообще не обязан быть частью возвращаемого значения буквально. Полезно в первую очередь для обработки изображений или аудио внутри воркера, где сам результат — тяжёлый бинарный буфер, копировать который туда-обратно через structured clone было бы чистой тратой памяти и времени.
Ошибки: два разных типа
Ошибка внутри хендлера сериализуется как { name, message, stack } и восстанавливается на главном потоке как WorkerError:
ts
class WorkerError extends Error {
readonly workerStack?: string // оригинальный стек, полученный из воркера
readonly cause?: Error // синтетическая ошибка, созданная в момент вызова run()
}
.cause создаётся не в момент реального падения хендлера, а заранее, синхронно, прямо в момент вызова run(), до ухода в воркер — благодаря этому в консоли или в Sentry видна полная картина сразу двух точек: где именно упало внутри воркера (.workerStack) и откуда конкретно был вызван run() на главном потоке (через .cause), а не только одна половина истории.
Отдельный тип — WorkerUnavailableError, который бросается вместо сырого ReferenceError: Worker is not defined, если run() вызван там, где глобального Worker попросту нет — типичный случай, SSR-рендер на сервере. Здесь как раз нашёлся реальный баг ещё на этапе разработки: изначально run() заворачивал любую ошибку, кроме AbortError, в generic WorkerError, а значит и WorkerUnavailableError из SSR-сценария долетал до вызывающего кода уже переупакованным в WorkerError — задокументированный тип, на который можно было бы написать instanceof-проверку, до пользователя фактически никогда не доезжал. Исправлено: WorkerUnavailableError теперь пробрасывается как есть, без обёртки и без применения ретраев, точно так же, как AbortError.
Worker pool: единственное место, где воркер реально быстрее
Важно проговорить это прямо, потому что интуиция часто подсказывает обратное: один воркер не делает вычисление быстрее — тот же самый процессор, плюс накладные расходы на сериализацию и postMessage. Ценность одного воркера — в том, что вычисление больше не конкурирует с рендером за один и тот же поток, а вовсе не в скорости как таковой. Проверял это специально на демо к пакету: одна и та же тяжёлая сортировка на 80 тысячах элементов, синхронно на главном потоке — около 880 мс, через один воркер — сопоставимое время, иногда даже чуть больше из-за накладных расходов на пересылку сообщений.
А вот пул из нескольких воркеров — это уже настоящий параллелизм на разных ядрах процессора, и вот здесь выигрыш в реальном, «настенном» времени самый честный:
ts
import { createWorkerPool } from 'vue-worker-kit/pool'
const pool = createWorkerPool<typeof import('./resize.worker')>(() =>
new Worker(new URL('./resize.worker.ts', import.meta.url), { type: 'module' }),
)
const thumbnails = await pool.map(files, { concurrency: pool.size })
size намеренно необязателен — по умолчанию берётся navigator.hardwareConcurrency, то есть реальное число логических ядер конкретной машины, на которой открыт сайт прямо сейчас, а не значение, придуманное на этапе разработки и захардкоженное в код. На SSR, где глобального navigator не существует, используется безопасный фолбэк — 4. Первая версия демо к этому пакету искусственно зажимала это число (Math.max(4, Math.min(hc, 12))) — на слабых машинах это означало переподписку (больше воркеров, чем реальных ядер), на мощных — искусственное урезание. Убрал клэмп совсем: пакет должен показывать честные числа, а не подгонять их под удобную демонстрацию.
pool.map() — сахар над pool.run() для каждого элемента с ограничением параллелизма через concurrency и результатами строго в исходном порядке, независимо от того, в каком порядке они реально досчитались. Воркеры пула создаются лениво, по мере поступления задач, а не все разом при вызове createWorkerPool() — если реальных задач за всю жизнь приложения оказалось меньше, чем size, лишние воркеры просто никогда не поднимутся. pool.stats — реактивный { busy, idle, queued }, готовый как основа для собственного индикатора загрузки.
Готовя демо к этому пакету, специально сравнил на своей машине (16 логических ядер) подсчёт простых чисел до 8 миллионов простым перебором делителей — намеренно неоптимизированная, по-настоящему тяжёлая CPU-нагрузка, а не имитация через setTimeout: 3104 мс последовательно на главном потоке против 661 мс через пул из 16 воркеров — реальное ускорение в 3.58 раза, при этом интерфейс на всё время параллельного прогона остаётся полностью отзывчивым. Devtools-панель во время такого прогона показывает все воркеры пула одновременно занятыми — наглядное, проверяемое доказательство параллелизма, а не просто обещание в документации.
useWorkerComputed() — реактивный computed внутри воркера
Самая нестандартная часть пакета: computed(), который пересчитывается в воркере при изменении реактивных зависимостей, с автоматическим отбрасыванием устаревших результатов:
ts
import { useWorkerComputed } from 'vue-worker-kit/computed'
const sorted = useWorkerComputed<typeof import('./heavy-sort.worker')>(
() => new Worker(new URL('./heavy-sort.worker.ts', import.meta.url), { type: 'module' }),
() => list.value, // отслеживается как источник, как в watchEffect
{ debounce: 150 },
)
// sorted.value — undefined до первого результата, дальше — последний АКТУАЛЬНЫЙ результат
// sorted.isRunning, sorted.error
Механика гонок построена вокруг внутреннего номера поколения (generation): у каждого запуска — свой номер, и если источник поменялся снова до того, как пришёл результат предыдущего запуска, у этого более старого результата просто нет шанса откатить sorted.value назад к устаревшему значению — при получении он молча отбрасывается по номеру поколения, а не по факту того, успел он прийти раньше или позже физически. Предыдущая, уже не нужная задача при этом помечается на кооперативную отмену через ctx.signal — тем же самым механизмом, что описан в разделе про отмену выше. debounce не даёт запускать воркер на каждый чих реактивности — например, на каждое нажатие клавиши при вводе текста в поле поиска.
Отдельная деталь, найденная не по теории, а по факту реального бага при тестировании, — и, пожалуй, самая интересная находка за всю разработку пакета. source()-геттер обычно возвращает ref/reactive-значение прямо из компонента (() => list.value), а Vue оборачивает такие значения в реактивный Proxy. И вот тут неожиданность: Vue-реактивный Proxy физически не клонируется через structuredClone ни в одном движке JavaScript, не только в Node — это не баг конкретного окружения, а фундаментальное ограничение того, как работает алгоритм структурного клонирования с объектами, обёрнутыми в прокси. Попытка отправить такое значение в воркер как есть падает с DataCloneError в любом реальном браузере — то есть ровно тот сценарий из документации ТЗ, который выглядит как «должно просто работать», на практике ломался бы у каждого, кто написал бы () => someReactiveValue.value буквально по примеру из README.
Пофиксил не точечно в одном месте, а на уровне общего клиентского протокола, который используют и useWorker, и пул, и useWorkerComputed: перед отправкой любых входных данных в воркер они автоматически прогоняются через toRaw(). Пользователю пакета вообще не нужно думать об этой особенности реактивности Vue — она просто не всплывает наружу.
Devtools-панель без @vue/devtools-api
ts
import { createWorkerActivityMonitor, WorkerActivityPanel } from 'vue-worker-kit/devtools'
const monitor = createWorkerActivityMonitor(pool) // или отдельный useWorker()-инстанс
vue
<WorkerActivityPanel :monitor="monitor" />
Панель показывает занято/свободно воркеров, длину очереди, среднее время задачи и последние N ошибок — реактивно, через подписку на внутренний activity-bus самого пакета, без единого поллинга. Сознательно не интеграция с браузерным расширением Vue Devtools — полноценная интеграция потребовала бы @vue/devtools-api как зависимость, что напрямую противоречит принципу «zero dependencies», на котором построен весь пакет. Вместо этого — самостоятельный, лёгкий debug-инструмент, который работает сразу после импорта, без второго шага настройки.
SSR — без сюрпризов на сервере
useWorker/useWorkerComputed/useWorkerPool безопасно вызывать в setup() на сервере: конструктор воркера передаётся не напрямую, а фабрикой (() => new Worker(...)) и вызывается только изнутри run() — то есть только на клиенте в обычных сценариях, потому что SSR по определению не рендерит клики и тяжёлые фоновые вычисления. Если код всё же попытается вызвать run() во время самого SSR-рендера (например, ошибочно, без <ClientOnly>) — вместо сырого ReferenceError: Worker is not defined, который трудно с ходу диагностировать в серверном логе, пакет бросает понятный WorkerUnavailableError, который, как описано выше, теперь не заворачивается ни в какой другой тип и никогда не попадает под авто-ретраи.
vue
<ClientOnly>
<ProgressBar v-if="isRunning" :value="progress" />
</ClientOnly>
Async/await против настоящего потока: что реально показывает демо
Это стоит раскрыть отдельно, потому что как раз здесь чаще всего рождаются неверные ожидания от любого worker-пакета. Собирая интерактивное демо к этому пакету, специально сделал наглядное сравнение: тот же самый алгоритм сортировки прогоняется и синхронно на главном потоке, и через useWorker — с индикатором, который постоянно двигается по экрану через requestAnimationFrame.
Первая версия индикатора была просто движущейся точкой — и здесь обнаружилась не техническая, а чисто «человеческая» проблема: движение нужно смотреть в реальном времени, а typичный способ оценить демо — щёлкнуть кнопку и посмотреть на результат уже после того, как всё случилось. На статике после клика движение точки, разумеется, ничего не доказывает. Заменил визуальную анимацию на конкретную, читаемую метрику постфактум: «longest UI freeze» — максимальный разрыв между двумя последовательными кадрами requestAnimationFrame за время всего прогона. Это не время выполнения (кто быстрее — вопрос отдельный и для одного воркера не в его пользу), а именно длительность реального подвисания интерфейса, число, которое можно прочитать уже после того, как всё закончилось.
При реализации этого замера всплыл отдельный, по-настоящему неожиданный технический нюанс: чтение накопленного значения сразу после окончания блокирующего цикла (даже спустя один тик requestAnimationFrame) стабильно давало старое, «до-фризовое» число. Оказалось — вновь запрошенный requestAnimationFrame-колбэк, зарегистрированный уже после долгого подвисания, успевает выполниться раньше, чем уже отложенный колбэк, зарегистрированный ещё до начала блокировки. Порядок FIFO по времени регистрации там, где ожидалось бы обратное, не гарантирован — это подтвердилось только эмпирически, через отладочные логи, а не через предположения о спецификации requestAnimationFrame. Починил ожиданием двух вложенных кадров подряд перед чтением значения.
Итоговые числа с настоящего браузера, не выдуманные для красоты: массив в 80 тысяч элементов, синхронная сортировка на главном потоке — самый долгий разрыв между кадрами анимации 217 мс, интерфейс на это время буквально не может ничего перерисовать. Та же самая сортировка через useWorker — 17 мс, то есть один нормальный, никем не замеченный кадр. Числа выполнения при этом почти не различаются — воркер честно не быстрее, просто он не крадёт время у интерфейса.
Чем это лучше конкурентов
vue-worker / vue-web-workers |
Comlink | vue-worker-kit | |
|---|---|---|---|
| Composition API | ✗ (Vue 2, 2017/2020) | — (не Vue-специфичен) | ✓ |
| Типизация входа/выхода | ✗ | ручной wrap<T>() |
выводится из файла воркера |
| Worker pool | ✗ | ✗ | ✓, с реальным ускорением |
| Реактивный computed в воркере | ✗ | ✗ | ✓ (useWorkerComputed) |
| Отмена | ✗ | ✗ | ✓ (AbortSignal), кооперативная и жёсткая |
| Transferable-объекты | ✗ | ✓ (вручную) | ✓, в обе стороны |
| Идемпотентный прогресс до 100% | ✗ | ✗ | ✓ |
| SSR-safe | ✗ | — | ✓ |
| Зависимости | — | нет | нет, кроме vue |
vue-worker (последняя версия 1.2.1, опубликована в мае 2017) и vue-web-workers (0.2.0, июль 2020, зависит от vue@^2.6.11 напрямую в dependencies) — фактически заброшенные Vue 2-плагины, это проверено напрямую по npm registry, а не по памяти или общему впечатлению. Comlink (4.4.2, до сих пор активно поддерживается, ноль зависимостей) — солидный, но Vue-агностичный RPC-слой без своей реактивности и без интеграции с жизненным циклом компонента: типизация там ручная, вызывающий код сам следит за жизненным циклом обёрнутого воркера.
Сценарии в реальных проектах
1. Тяжёлая фильтрация и сортировка большой таблицы
Классика для админок и BI-дашбордов: таблица на десятки тысяч строк, загруженная целиком на клиент, пользователь одновременно фильтрует по нескольким колонкам и сортирует по клику на заголовок. На главном потоке это реально ощутимый фриз при каждом изменении — тем более ощутимый, чем сложнее логика фильтрации (несколько условий, поиск по подстроке в нескольких полях, вычисляемые агрегаты).
ts
import { useWorkerComputed } from 'vue-worker-kit/computed'
const rawRows = ref<Row[]>([]) // 50k+ строк, загружены один раз при монтировании
const filters = reactive({ status: 'active', minAmount: 0, search: '' })
const sortKey = ref<keyof Row>('date')
const filteredAndSorted = useWorkerComputed<typeof import('./tableProcessor.worker')>(
() => new Worker(new URL('./tableProcessor.worker.ts', import.meta.url), { type: 'module' }),
() => ({ rows: rawRows.value, filters, sortKey: sortKey.value }),
{ debounce: 100 },
)
ts
// tableProcessor.worker.ts
import { defineWorkerHandler } from 'vue-worker-kit/worker'
export default defineWorkerHandler((input: TableProcessorInput) => {
return input.rows
.filter((row) => matchesFilters(row, input.filters))
.sort((a, b) => compareBy(a, b, input.sortKey))
})
Пользователь щёлкает фильтрами и заголовками сортировки сколько угодно быстро подряд — интерфейс остаётся отзывчивым в любой момент, даже пока идёт пересчёт. debounce не даёт запускать воркер на каждый промежуточный клик, если пользователь быстро переключает несколько фильтров подряд, а устаревшие результаты (если пользователь успел поменять фильтр ещё раз, пока считался предыдущий вариант) отбрасываются автоматически по номеру поколения — без единой строчки ручной логики отмены гонки со стороны разработчика компонента.
2. Пакетная обработка загружаемых файлов
Пользователь одновременно перетаскивает в форму загрузки 30 фотографий, каждую нужно уменьшить и пережать перед отправкой на сервер — типичная задача для форм с вложениями, галерей, конструкторов объявлений. Ждать этого строго последовательно на главном потоке — секунды видимого зависания интерфейса на каждый файл; поднимать по отдельному воркеру на каждый файл без ограничения — не использовать реально доступные ядра процессора эффективно, а на слабом устройстве ещё и рисковать переподпиской.
ts
import { useWorkerPool } from 'vue-worker-kit/pool'
import { createWorkerActivityMonitor, WorkerActivityPanel } from 'vue-worker-kit/devtools'
const pool = useWorkerPool<typeof import('./resize.worker')>(() =>
new Worker(new URL('./resize.worker.ts', import.meta.url), { type: 'module' }),
)
const monitor = createWorkerActivityMonitor(pool)
async function handleFilesSelected(files: File[]) {
const buffers = await Promise.all(files.map((f) => f.arrayBuffer()))
const resized = await pool.map(
buffers.map((buffer, i) => ({ buffer, name: files[i].name })),
{ concurrency: pool.size },
)
await uploadAll(resized)
}
vue
<WorkerActivityPanel v-if="isDevMode" :monitor="monitor" />
pool.size сам подстроится под то, сколько логических ядер реально есть у конкретного пользователя — на слабом ноутбуке или телефоне обработка не переподпишет несуществующие ядра избыточным числом воркеров, на мощной рабочей станции — использует все доступные. Devtools-панель во время разработки сразу наглядно показывает, равномерно ли распределяется нагрузка между воркерами пула, или, например, один из файлов оказался аномально тяжёлым и держит один из воркеров занятым дольше остальных.
3. Поиск по большому локальному индексу «как вы печатаете»
Поле поиска по каталогу из нескольких тысяч товаров, загруженному целиком на клиент — частый случай для внутренних инструментов, небольших каталогов и приложений, где нет смысла гонять запрос на бэкенд на каждый введённый символ. Полнотекстовый поиск с ранжированием по релевантности сразу по нескольким полям — не тривиальная по стоимости операция, и прогонять её на главном потоке на каждое нажатие клавиши означает заметные лаги прямо во время печати, то есть в самый чувствительный к задержке момент взаимодействия.
ts
import { useWorkerComputed } from 'vue-worker-kit/computed'
const query = ref('')
const catalog = ref<Product[]>([]) // загружен один раз при монтировании
const results = useWorkerComputed<typeof import('./search.worker')>(
() => new Worker(new URL('./search.worker.ts', import.meta.url), { type: 'module' }),
() => ({ catalog: catalog.value, query: query.value }),
{ debounce: 120 },
)
Здесь debounce — не просто мелкая оптимизация, а настоящая необходимость: без него каждый нажатый символ запускал бы полный проход по всему каталогу внутри воркера, и результаты нескольких пересекающихся по времени запросов начали бы конкурировать друг с другом за то, какой из них в итоге окажется в results.value. Генерационная логика useWorkerComputed() гарантирует конкретную вещь: даже если два запроса физически пересекутся по времени выполнения (например, воркер на секунду притормозил на каком-то особенно тяжёлом запросе), в results.value в конце концов окажется ответ именно на последний введённый запрос, а не на тот, чей ответ случайно долетел раньше по времени.
Итого
vue-worker-kit не пытается прятать за собой реальную природу воркеров — наоборот, в README есть отдельный раздел про то, что async/await и Web Worker решают принципиально разные задачи, и один воркер сам по себе не быстрее главного потока, только не блокирует его; настоящий выигрыш в скорости — только через пул на нескольких ядрах, и это тоже честно показано цифрами, а не декларацией. Пакет даёт ровно то, чего действительно не хватало в экосистеме: типизацию, выведенную из файла воркера без единого ручного дженерика, пул с честным ускорением, реактивный computed() внутри воркера, отмену через стандартный AbortSignal в двух режимах и transferable-объекты в обе стороны без единой лишней копии в памяти. Ядро — ~2.3 KB gzip, всё остальное — по требованию, ни байта лишнего в бандле. Покрытие тестами — 95%, включая тесты, которые честно гоняют структурное клонирование и детач буферов, а не полагаются на примитивные моки.
NPM: https://www.npmjs.com/package/vue-worker-kit
GitHub: https://github.com/macrulezru/vue-worker-kit