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

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

Представьте таблицу на 50 тысяч строк с фильтрами по нескольким колонкам и сортировкой по клику на заголовок. Пользователь щёлкает фильтром — и на полсекунды, а то и на секунду, всё замирает: не крутится спиннер, не реагирует ни одна кнопка, скролл дёргается. Первая мысль почти у каждого: «сделаю пересчёт в async function, пусть будет асинхронным». Пишете async, ставите await перед тяжёлым циклом — и ничего не меняется. Фриз остаётся ровно таким же.

Причина в том, что async/await в браузере не выносит работу на другой поток — вообще никак. JavaScript в браузере (вне воркеров) всегда выполняется в одном-единственном потоке, сколько бы async-функций вы ни написали. Есть два принципиально разных смысла, которые люди вкладывают в слово «асинхронно»:

  • Ожидание I/Ofetch, 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 Copy
npm install vue-worker-kit

Поставляется как tree-shakeable ESM и CommonJS одновременно, с полными TypeScript-типами для всех опций, контекста воркера и возвращаемых значений. Zero runtime dependencies — только vue в peer, ни у ядра, ни у одной из опциональных частей. Каждая часть пакета — отдельная точка входа в exports, которая физически не попадает в бандл, пока вы её явно не импортировали:

Copy
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 Copy
// 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 Copy
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 Copy
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 Copy
const controller = new AbortController()
const promise = run(hugeArray, { signal: controller.signal })

controller.abort() // promise реджектится AbortError немедленно

Если свой signal не передавать — useWorker() создаёт его сам внутри, а метод cancel(), возвращаемый композаблом, абортит именно этот внутренний контроллер. По умолчанию воркер при отмене продолжает жить и работать в фоне — терминировать поток и поднимать его заново дороже, чем просто перестать интересоваться результатом конкретного вызова. Но для сценариев, где важно гарантированно и немедленно остановить именно вычисление (например, пользователь ушёл со страницы с очень тяжёлой задачей, и вы хотите освободить память и CPU прямо сейчас, а не когда-нибудь), есть явный флаг:

ts Copy
useWorker<typeof import('./heavy-sort.worker')>(factory, { hardCancelOnAbort: true })

При hardCancelOnAbort: true вызов abort() реально терминирует воркер немедленно и прозрачно поднимает новый на следующий run() — компонент, использующий композабл, не замечает разницы в API, только в поведении под капотом.


Жизненный цикл воркера: idle timeout, ретраи, автотерминация

Три опции useWorker(), которые редко замечают в документации других пакетов, но которые реально важны в долгоживущем приложении:

ts Copy
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 Copy
const buffer = new ArrayBuffer(10 * 1024 * 1024)
const result = await run(buffer, { transfer: [buffer] })
// buffer.byteLength === 0 сразу же после вызова — буфер detached, а не скопирован

Обратное направление — вернуть большой буфер из воркера обратно на главный поток без копирования — во многих подобных пакетах вообще не поддерживается, и в первой версии этого пакета тоже не было. Добавил симметричный вызов внутри хендлера, ctx.transfer():

ts Copy
// 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 Copy
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 Copy
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 Copy
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 Copy
import { createWorkerActivityMonitor, WorkerActivityPanel } from 'vue-worker-kit/devtools'

const monitor = createWorkerActivityMonitor(pool) // или отдельный useWorker()-инстанс
vue Copy
<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 Copy
<ClientOnly>
  <ProgressBar v-if="isRunning" :value="progress" />
</ClientOnly>

Async/await против настоящего потока: что реально показывает демо

Это стоит раскрыть отдельно, потому что как раз здесь чаще всего рождаются неверные ожидания от любого worker-пакета. Собирая интерактивное демо к этому пакету, специально сделал наглядное сравнение: тот же самый алгоритм сортировки прогоняется и синхронно на главном потоке, и через useWorker — с индикатором, который постоянно двигается по экрану через requestAnimationFrame.

Первая версия индикатора была просто движущейся точкой — и здесь обнаружилась не техническая, а чисто «человеческая» проблема: движение нужно смотреть в реальном времени, а typичный способ оценить демо — щёлкнуть кнопку и посмотреть на результат уже после того, как всё случилось. На статике после клика движение точки, разумеется, ничего не доказывает. Заменил визуальную анимацию на конкретную, читаемую метрику постфактум: «longest UI freeze» — максимальный разрыв между двумя последовательными кадрами requestAnimationFrame за время всего прогона. Это не время выполнения (кто быстрее — вопрос отдельный и для одного воркера не в его пользу), а именно длительность реального подвисания интерфейса, число, которое можно прочитать уже после того, как всё закончилось.

При реализации этого замера всплыл отдельный, по-настоящему неожиданный технический нюанс: чтение накопленного значения сразу после окончания блокирующего цикла (даже спустя один тик requestAnimationFrame) стабильно давало старое, «до-фризовое» число. Оказалось — вновь запрошенный requestAnimationFrame-колбэк, зарегистрированный уже после долгого подвисания, успевает выполниться раньше, чем уже отложенный колбэк, зарегистрированный ещё до начала блокировки. Порядок FIFO по времени регистрации там, где ожидалось бы обратное, не гарантирован — это подтвердилось только эмпирически, через отладочные логи, а не через предположения о спецификации requestAnimationFrame. Починил ожиданием двух вложенных кадров подряд перед чтением значения.

Итоговые числа с настоящего браузера, не выдуманные для красоты: массив в 80 тысяч элементов, синхронная сортировка на главном потоке — самый долгий разрыв между кадрами анимации 217 мс, интерфейс на это время буквально не может ничего перерисовать. Та же самая сортировка через useWorker17 мс, то есть один нормальный, никем не замеченный кадр. Числа выполнения при этом почти не различаются — воркер честно не быстрее, просто он не крадёт время у интерфейса.


Чем это лучше конкурентов

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 Copy
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 Copy
// 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 Copy
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 Copy
<WorkerActivityPanel v-if="isDevMode" :monitor="monitor" />

pool.size сам подстроится под то, сколько логических ядер реально есть у конкретного пользователя — на слабом ноутбуке или телефоне обработка не переподпишет несуществующие ядра избыточным числом воркеров, на мощной рабочей станции — использует все доступные. Devtools-панель во время разработки сразу наглядно показывает, равномерно ли распределяется нагрузка между воркерами пула, или, например, один из файлов оказался аномально тяжёлым и держит один из воркеров занятым дольше остальных.

3. Поиск по большому локальному индексу «как вы печатаете»

Поле поиска по каталогу из нескольких тысяч товаров, загруженному целиком на клиент — частый случай для внутренних инструментов, небольших каталогов и приложений, где нет смысла гонять запрос на бэкенд на каждый введённый символ. Полнотекстовый поиск с ранжированием по релевантности сразу по нескольким полям — не тривиальная по стоимости операция, и прогонять её на главном потоке на каждое нажатие клавиши означает заметные лаги прямо во время печати, то есть в самый чувствительный к задержке момент взаимодействия.

ts Copy
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

Читать далее

vue-error-boundary-kit: error boundaries для Vue 3, каких не хватало в экосистеме

18.07.2026

У React error boundaries — стандартный паттерн уже восемь лет. У Vue 3 есть только низкоуровневый onErrorCaptured, вокруг которого каждый проект заново собирает fallback-UI, retry и репортинг в Sentry. Написал vue-error-boundary-kit — готовый компонент с retry, дедупом ошибок и адаптерами под пять систем репортинга.

Метки
vuevue3typescripterror-handlingopensource

rest-pipeline-js 2.0.0 - распределённый rate limiter, circuit breaker, трассировка запросов и идемпотентность

17.07.2026

Мажорный релиз 2.0.0: rate limiter и circuit breaker научились работать в кластере через общий backend (например, Redis), запросы можно трассировать по W3C Trace Context, а мутирующие запросы — безопасно ретраить с помощью idempotency-ключей. Заодно поймал и починил баг, из-за которого пакет вообще не грузился нативным Node ESM.

Метки
rest-pipeline-jstypescriptnodejsmicroservicesopensource

vue-virtual-scroller-kit 0.2: большое обновление — RTL, кастомный скроллбар и ещё десяток новых возможностей

17.07.2026

Самое большое обновление vue-virtual-scroller-kit с момента первого релиза: выбор строк по клику и Shift+click, асинхронный поиск в VirtualSelect, полное управление колонками таблицы, горизонтальные списки, отслеживание видимости через IntersectionObserver, кастомный скроллбар, RTL из коробки, а также motion blur, sticky-заголовки групп и ещё несколько фич поменьше. В посте — подробный разбор каждой с примерами и три сценария из реальных проектов.

Метки
vue3frontendvirtual-scrolltypescriptopensource