vue-worker-kit 0.2.0: один воркер на все вкладки браузера

20.08.2026
vue-worker-kit 0.2.0: один воркер на все вкладки браузера

Открываете свою же админку в пяти вкладках подряд — потому что удобно держать разные разделы рядом — и каждая вкладка честно поднимает собственный Worker под тяжёлую задачу: свою декодировку, свой парсинг, свой отдельный WebSocket до сервера. Пять вкладок — пять независимых потоков, которые считают одно и то же, каждый со своего нуля, и пять независимых сетевых соединений там, где по-хорошему нужно одно. useWorker() и createWorkerPool() из vue-worker-kit эту задачу для одной вкладки решают полностью — но они принципиально ничего не знают про соседние вкладки, потому что обычный Worker в браузере привязан именно к тому документу, который его создал, и живёт и умирает вместе с ним.

В версии 0.2.0 закрыл именно этот пробел — useSharedWorker(), тот же самый протокол run/cancel/progress, что и у обычного useWorker(), но поверх SharedWorker, отдельного вида воркера из спецификации, который переживает конкретную вкладку и обслуживает все вкладки одного источника одновременно. Заодно добавилось несколько вещей, которые давно просились в обычный useWorker(), но не попали в первую версию: потоковые чанки результата вместо ожидания полного завершения, LRU-кэш по входным данным, ретраи с настраиваемой задержкой между попытками и warmup() для прогрева воркера заранее, до того как он реально понадобится. Ниже — подробно про каждую фичу, с механикой под капотом, примерами и парой честных историй про баги, найденные уже на финальном ревью, когда всё, казалось, было готово и покрыто тестами.


useSharedWorker() — один воркер на все вкладки

Почему это не тот же useWorker() с другим конструктором

Разница между Worker и SharedWorker — не в названии, а в модели обмена сообщениями целиком. Обычный воркер получает сообщения через self.onmessage — один канал, один поток входящих команд. SharedWorker вместо этого получает self.onconnect — по одному вызову на каждую подключающуюся вкладку, и каждый такой вызов отдаёт отдельный MessagePort, персональный канал именно для этой вкладки. Из-за этого протокол run/cancel/progress, который в обычном воркере вешается один раз на self, для SharedWorker приходится вешать заново на каждый новый порт — с собственным пространством id-шников запроса и собственным набором AbortController'ов, чтобы отмена задачи в одной вкладке физически не могла дотянуться до запроса другой вкладки с тем же самым числовым id:

ts Copy
export function attachSharedWorkerProtocol<In, Out>(
  handler: WorkerHandlerFn<In, Out>,
  scope: SharedWorkerScopeLike = self as unknown as SharedWorkerScopeLike,
): void {
  const ports = new Set<MessagePortLike>()

  scope.onconnect = (event) => {
    const port = event.ports[0]
    ports.add(port)
    broadcastPortCount()

    // Тот же самый attachWorkerProtocol(), что и для обычного Worker — но на отдельном
    // "виртуальном" scope, который просто проксирует postMessage в конкретный port этой
    // вкладки. Хендлеру вообще не важно, что он сейчас работает внутри SharedWorker.
    const portScope: WorkerScopeLike = {
      postMessage: (message, transfer) => port.postMessage(message, transfer),
      onmessage: null,
    }
    const protocol = attachWorkerProtocol(handler, portScope)
    port.start()
  }
}

Главный практический эффект этой архитектуры: сам файл воркера, написанный через defineWorkerHandler(), вообще не меняется в зависимости от того, каким API его поднимают. Один и тот же .worker.ts работает и с new Worker(...), и с new SharedWorker(...):

ts Copy
// shared.worker.ts — обычный defineWorkerHandler(), без единой строчки специально под SharedWorker
import { defineWorkerHandler } from 'vue-worker-kit/worker'

// Генерируется один раз при старте SharedWorkerGlobalScope, а не на каждое подключение —
// конкретное, проверяемое доказательство того, что вкладки говорят с одним и тем же
// воркером, а не каждая со своим отдельным экземпляром.
const workerInstanceId = Math.random().toString(36).slice(2, 8)

export default defineWorkerHandler((input: { value: number }) => ({
  doubled: input.value * 2,
  workerInstanceId,
}))
ts Copy
// на главном потоке, в любой из вкладок — API один в один похож на useWorker()
import { useSharedWorker } from 'vue-worker-kit/shared'

const { run, connect, disconnect, portCount } = useSharedWorker<typeof import('./shared.worker')>(() =>
  new SharedWorker(new URL('./shared.worker.ts', import.meta.url), { type: 'module' }),
)

const result = await run({ value: 21 })
// result.workerInstanceId — одинаковый в каждой вкладке, где вы это запустите, потому что
// это буквально один и тот же процесс воркера на всех

Чем useSharedWorker() осознанно отличается от useWorker()

Не всё API переносится дословно — часть опций у обычного воркера просто не имеет смысла для общего на всех:

useWorker() useSharedWorker()
idleTimeout есть, самотерминация по простою нет — жизненным циклом владеет не одна вкладка
hardCancelOnAbort есть нет — терминировать воркер для всех вкладок ради отмены задачи одной было бы неправильно
retries/retryDelay/cache/streaming есть те же самые
Завершение работы автотерминация по onScopeDispose disconnect() — закрывает порт этой вкладки, не трогая остальные

Место idleTimeout/hardCancelOnAbort занимает явная пара connect()/disconnect(): connect() устанавливает соединение этой вкладки (не обязателен — run() подключается лениво сам, если про это забыть), а disconnect() закрывает именно её порт, никак не задевая воркер для остальных вкладок, которые остаются подключены.

Честное число вместо красивого

portCount — реактивное число вкладок, о подключении которых воркер знает прямо сейчас, транслируется во все подключённые порты при каждом изменении. Тут пришлось сразу разбираться с честностью самого числа: у MessagePort нет платформенного события «с той стороны пропали». Если вкладку не закрыть аккуратно через disconnect(), а просто убить — крэш вкладки, force-close, разрыв процесса — воркер об этом никогда не узнает никаким штатным способом. Решение — не тащить portCount в сторону обманчивой точности: оно растёт на каждое подключение и гарантированно уменьшается только на кооперативный disconnect(), а про упавшую без предупреждения вкладку честно молчит, вместо того чтобы подделывать точность, которой физически не может быть у этого API.

Проверял всё это не в юнит-тестах с моками портов, а вживую через Playwright с несколькими настоящими вкладками одного и того же браузера, открытыми одновременно: workerInstanceId совпадает во всех, portCount считает правильно при подключении и при кооперативном отключении, а принудительно закрытая без disconnect() вкладка честно остаётся в счётчике до тех пор, пока кто-то другой не отключится по-настоящему.

Найденный при повторном ревью баги

disconnect() оставлял задачу висеть в фоне

Уже после того, как всё было готово и покрыто тестами, при повторном ревью протокола всплыла неаккуратность: если вкладка вызывает disconnect() посреди ещё не завершённого run(), клиентская сторона честно реджектит промис немедленно (через внутренний client.dispose()), а вот воркер про это вообще не узнавал — хендлер продолжал считать в фоне ради клиента, который уже закрыл свой порт и никогда не увидит результат. Не катастрофа сама по себе (порт всё равно закрывается, память в итоге освобождается), но и не то поведение, которое ожидаешь от пакета, где кооперативная отмена — сквозной принцип буквально везде: cancel() абортит ctx.signal, terminate() останавливает воркер, а вот disconnect() на воркер-стороне никак не давал знать о своём происходящем.

Починил на уровне протокола, а не точечно: attachWorkerProtocol() теперь возвращает небольшой объект-хендл с методом abortAll(), который абортит все ещё не завершённые контроллеры, зарегистрированные на этом конкретном scope/порте:

ts Copy
export function attachWorkerProtocol<In, Out>(
  handler: WorkerHandlerFn<In, Out>,
  scope: WorkerScopeLike = self as unknown as WorkerScopeLike,
): WorkerProtocolHandle {
  const controllers = new Map<number, AbortController>()
  // ... вся обычная логика run/cancel, как и раньше ...

  return {
    abortAll(reason) {
      for (const controller of controllers.values()) controller.abort(reason)
    },
  }
}

А disconnect-ветка на воркер-стороне вызывает его перед закрытием порта:

ts Copy
port.onmessage = (event) => {
  if (event.data.type === 'disconnect') {
    protocol.abortAll(new Error('SharedWorker port disconnected'))
    ports.delete(port)
    port.close()
    broadcastPortCount()
    return
  }
  portScope.onmessage?.(event)
}

Тот же самый ctx.signal, который хендлер и так может проверять внутри своего цикла — тот же кооперативный механизм, что и у обычной отмены — абортится немедленно. Написал отдельный тест именно на этот сценарий: запускаю задачу, изнутри хендлера подписываюсь на abort-событие ctx.signal, шлю disconnect с клиентской стороны и проверяю, что событие реально долетело до хендлера, а не просто «должно было долететь по логике кода».

Неподдерживаемый браузер валил всю страницу

SharedWorker не существует вообще в Safari iOS и Chrome Android — не баг конкретной версии, архитектурное решение этих браузеров, которое вряд ли изменится. connect()/run() в таком окружении бросают WorkerUnavailableError — ту же самую ошибку, которую useWorker() бросает при вызове на сервере во время SSR, где тоже нет глобального Worker.

При обновлении демо к пакету по этой же причине нашёлся отдельный, более неприятный баг: вызов sharedWorker.connect() стоял прямо на верхнем уровне <script setup>, без единой обёртки. На неподдерживаемом браузере это не просто ломало одну секцию демо про useSharedWorker — необработанное исключение на этапе setup() останавливает выполнение всего компонента целиком, и падала вся демо-страница со всеми остальными секциями сразу, включая те, что вообще никак не связаны с SharedWorker. Починил простой проверкой поддержки до вызова connect():

ts Copy
const sharedWorkerSupported = typeof SharedWorker !== 'undefined'

if (sharedWorkerSupported) {
  sharedWorker.connect()
}

и явным сообщением в интерфейсе вместо тихого (точнее, очень громкого) падения, если поддержки нет. Мелкая деталь, но именно из категории тех, что легко пропустить, потому что «на моей машине же работает» — Chrome на десктопе, с которого обычно разрабатывают, SharedWorker поддерживает исправно.


Потоковые результаты вместо ожидания полного завершения

Раньше воркер мог либо отчитаться о прогрессе числом от 0 до 1 через ctx.reportProgress(), либо вернуть готовый результат целиком через return — среднего варианта не было. Для длинных операций, где промежуточные данные сами по себе полезны (построчный разбор файла, постепенный рендер большого списка, живой предпросмотр по мере обработки), это означало ждать самого конца ради первой же полезной строчки. streaming: true плюс ctx.reportChunk() закрывают именно этот разрыв — добавился отдельный тип сообщения в протокол, chunk, параллельно уже существующим progress/result/error:

ts Copy
// process.worker.ts
import { defineWorkerHandler } from 'vue-worker-kit/worker'

export default defineWorkerHandler(async (items: Row[], ctx) => {
  const results: Result[] = []
  for (let i = 0; i < items.length; i += 100) {
    const batch = await processBatch(items.slice(i, i + 100))
    ctx.reportChunk(batch) // уходит на главный поток сразу, без троттлинга
    results.push(...batch)
  }
  return results // финальный результат — отдельно от накопленных чанков
})
ts Copy
const { run, chunks } = useWorker<typeof import('./process.worker')>(factory, { streaming: true })

watch(chunks, (all) => {
  console.log('новый чанк только что пришёл:', all[all.length - 1])
})

const finalResult = await run(items)

chunksShallowRef<unknown[]>, накапливает все чанки строго по порядку их отправки и существует в возвращаемом объекте только при явном streaming: true — без этого флага там undefined, а не пустой массив, и watch(chunks, ...) без флага просто ничего не будет отслеживать. В отличие от reportProgress, чанки принципиально не троттлятся: у прогресса троттлинг оправдан, потому что промежуточные значения не важны сами по себе, важна только финальная цифра; у чанка же каждый — самостоятельная единица полезных данных, терять их из-за троттлинга означало бы терять реальную информацию, а не просто «пропустить один тик индикатора».

Работает всё это через тот же самый защитный механизм, что появился в 0.1.0 при разборе протокольных сообщений: любое сообщение с типом, отличным от 'run', никогда не читается как запрос на выполнение — так что добавление нового типа chunk в протокол не рискует случайно быть перепутанным с чем-то ещё на другой стороне.


Кэширование и ретраи с задержкой

LRU-кэш по входным данным

Для чистых функций — один и тот же вход всегда даёт один и тот же выход — включается LRU-кэш прямо в опциях useWorker()/useSharedWorker(), без единой строчки собственной логики мемоизации на стороне компонента:

ts Copy
const { run } = useWorker<typeof import('./hash.worker')>(factory, {
  cache: { cache: 'lru', maxCacheSize: 100 }, // по умолчанию maxCacheSize — 50
})

const a = await run(data) // реально считает в воркере, полный round-trip postMessage
const b = await run(data) // тот же вход — мгновенный ответ из кэша, ни одного сообщения воркеру

Ключ кэша — JSON.stringify(input), экспортирован отдельно как createCacheKey(), если нужно самостоятельно порассуждать про коллизии или, например, логировать реальные ключи при отладке. Важная сознательная граница: useWorkerComputed() этой опции не получил и не получит — у него уже есть собственный механизм отбрасывания устаревших результатов по номеру поколения, а source()-геттер там почти всегда производит новый инпут на каждый реактивный тик, так что ключевой мемоизации попросту почти не за что было бы зацепиться.

Ретраи с задержкой между попытками

retries из первой версии пакета обзавёлся парой — retryDelay, задержкой между автоматическими повторами при реджекте, число или функция от номера попытки:

ts Copy
// постоянная задержка между всеми попытками
useWorker<typeof import('./api.worker')>(factory, { retries: 3, retryDelay: 500 })

// экспоненциальный backoff с потолком
useWorker<typeof import('./api.worker')>(factory, {
  retries: 3,
  retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10_000), // 1с, 2с, 4с, не больше 10с
})

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


warmup() — прогрев без ожидания реальной задачи

Создание воркера — не бесплатная операция: браузеру нужно разобрать скрипт и поднять новый OS-поток, на практике это обычно 50–200 мс, иногда больше на медленном устройстве. Если заранее известно, что тяжёлая операция скоро понадобится — например, пользователь открыл модалку с обработкой файла, но ещё не нажал «начать», — эту задержку можно спрятать заранее, пока он читает интерфейс модалки:

ts Copy
const { warmup, run } = useWorker<typeof import('./resize.worker')>(factory)

onMounted(() => warmup()) // воркер уже поднят и ждёт, реальная задача ещё не запущена

// позже, по клику пользователя — без единой лишней миллисекунды на создание потока
const result = await run(input)

То же самое есть и у пула — pool.warmup() поднимает сразу все воркеры вплоть до pool.size, а не по одному лениво по мере поступления задач, как это происходит в обычном режиме работы пула:

ts Copy
const pool = createWorkerPool<typeof import('./resize.worker')>(factory, { size: 8 })
await pool.warmup() // все 8 воркеров подняты одновременно и готовы
const results = await pool.map(items) // ни один из них не создаётся заново на старте

pool.map(): отмена и transferables на каждый элемент

pool.map() и раньше умел ограничивать параллелизм через concurrency, но не давал ни отменить всю пачку разом, ни передать буферы zero-copy для отдельных элементов — приходилось либо мириться со структурным клонированием каждого файла, либо городить собственную логику поверх. Теперь умеет оба сразу:

ts Copy
const thumbnails = await pool.map(files, {
  concurrency: pool.size,
  transfer: (file) => [file.buffer], // zero-copy на каждый элемент отдельно, а не на всю пачку разом
  signal: abortController.signal,    // общий сигнал отмены на всю пачку целиком
})

signal полезен ровно там, где раньше приходилось городить собственный Promise.race вокруг всего вызова pool.map(): пользователь ушёл со страницы или сам отменил загрузку целиком — и все ещё не досчитанные элементы пачки останавливаются одним вызовом abort(), а уже готовые к этому моменту результаты никуда не теряются, просто перестают ждать оставшихся.


Devtools-панель заговорила и про useSharedWorker

createWorkerActivityMonitor() раньше принимал только пул или отдельный useWorker()-инстанс — проверка шла простым дак-тайпингом, наличием поля stats у переданного объекта:

ts Copy
function isPoolSource(source: WorkerActivitySource): source is WorkerPool<unknown, unknown> {
  return 'stats' in source
}

useSharedWorker() этого поля не имеет, так же как и обычный useWorker() — то есть структурно он и так уже подходил под ветку «одиночный воркер», просто тип WorkerActivitySource об этом явно не знал. Расширение оказалось однострочным по сути (добавить UseSharedWorkerReturn в объединение типов), но по-честному стоило отдельного теста, а не предположения «раз структура совпадает по форме, значит и в рантайме сработает как надо»:

ts Copy
const sharedWorker = useSharedWorker<typeof import('./shared.worker')>(factory)
const monitor = createWorkerActivityMonitor(sharedWorker) // теперь работает и так же, как для пула
vue Copy
<WorkerActivityPanel :monitor="monitor" />

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


Под капотом: инфраструктура, безопасность и один упрямый баг в CI

Не фичи в смысле публичного API, но часть того же самого релиза — и здесь тоже нашлось на что посмотреть.

CI, который сначала честно упал

Появился пайплайн на GitHub Actions — typecheck, lint, тесты и сборка на каждый пуш и PR, отдельными джобами для самого пакета и для demo/. Первый же прогон на GitHub красиво упал прямо на шаге typecheck внутри джобы demo:

Copy
Error: ../src/adapters/computed.ts(1,78): error TS2307: Cannot find module 'vue' or its corresponding type declarations.
Error: ../src/useWorker.ts(1,76): error TS2307: Cannot find module 'vue' or its corresponding type declarations.
... (ещё девять таких же строк)

Локально всё было зелёным, а на GitHub — нет, классический признак того, что где-то неявно используется состояние, которое есть на машине разработчика «просто потому что», но не гарантировано в чистом окружении. Причина оказалась в самой архитектуре демо: demo/tsconfig.json алиасит vue-worker-kit/* прямо на ../src/*.ts — на реальный исходник пакета, а не на собранный dist/, чтобы демо всегда гоняло актуальный код без пересборки. А значит, typecheck демо в какой-то момент реально типизирует файлы, которые физически лежат вне папки demo/, в корне репозитория. Модульное разрешение Node для этих файлов поднимается вверх по дереву от их собственного расположения — то есть от корневого src/, а не от demo/ — и в чистом CI-чекауте корневой node_modules (где как раз лежит vue) там просто ещё не был установлен: джоба demo до этого честно ставила зависимости только внутри demo/.

Воспроизвёл локально один в один — удалил корневой node_modules, оставив только demo/node_modules, и получил ровно ту же самую ошибку слово в слово, что подтвердило диагноз ещё до правки. Починил добавлением отдельного npm ci в корне репозитория перед шагами демо-джобы, с комментарием прямо в workflow-файле, почему это обязательный шаг, а не лишний — чтобы никто в будущем не убрал его как «дублирующий».

Аудит зависимостей: 20 уязвимостей → 0

Заодно прогнал npm audit по обоим package.json в репозитории — накопилось двадцать уязвимостей, включая одну критическую в happy-dom (устаревшая версия была уязвима к побегу из VM-песочницы при рендере — в контексте тестового окружения не катастрофа, но и не то, что хочется держать в devDependencies без причины). Часть цепочки уязвимостей тянулась неожиданно глубоко: vite-plugin-dts третьей версии жёстко тащил за собой @microsoft/api-extractor, а вместе с ним — устаревшие ajv, lodash и minimatch с известными ReDoS-проблемами. Пятая версия того же vite-plugin-dts архитектурно переехала на unplugin-dts и сделала @microsoft/api-extractor опциональной peer-зависимостью — и этой одной функции пакет вообще не использует, так что вся цепочка исчезла сама по себе после обновления мажорной версии.

Обновление vite 5 → 8 и vitest 2 → 4 попутно потянуло за собой и честность в другую сторону: package.json обещал node >= 18, но актуальные eslint, vite, vue-tsc и vitest уже независимо друг от друга отказались от поддержки Node 18 — он в EOL с апреля 2025 года, и экосистема двинулась дальше. Проверил это не по документации, а буквально прогнав npm ci под настоящим Node 18 и прочитав каждое предупреждение EBADENGINE до единого. Поправил engines.node, чтобы он отражал реальность, а не то, что было написано когда-то давно и с тех пор ни разу не перепроверялось. На пакет, который вы устанавливаете в свой проект, это никак не влияет — dist/ как был обычным браузерным JS без зависимости от версии Node, так и остался; речь только про сборку самого пакета из исходников.

Пара мелочей из code review, о которых приятно рассказать

.gitignore игнорировал .vscode/ целиком, с исключениями для extensions.json/settings.json ниже — и эти исключения молча не работали. Причина в самой механике git: если директория исключена как директория (.vscode/, со слэшем на конце), git вообще не заходит внутрь неё для проверки более специфичных правил, и попытка «расисключить» отдельный файл внутри такой директории ни к чему не приводит. Правильный паттерн — исключать содержимое (.vscode/*), тогда точечные исключения файлов внутри реально применяются. Проверил разницу эмпирически через git check-ignore -v со старым и новым паттерном на одних и тех же файлах — со старым паттерном оба файла подтверждённо считались игнорируемыми вопреки исключениям, с новым — нет.

Второе — GitHub Actions по умолчанию не ограничивает права токена, которым воркфлоу пользуется от имени репозитория, и actions/checkout по умолчанию сохраняет учётные данные в конфиге git внутри раннера. Ни то, ни другое здесь реально не нужно — воркфлоу ничего не пушит и никуда не пишет, только читает, типизирует, тестирует и собирает. Добавил permissions: contents: read на весь workflow и persist-credentials: false на оба чекаута — дешёвое, стандартное ужесточение по принципу наименьших привилегий, а не реакция на конкретную угрозу.


Сценарии в реальных проектах

1. Одно соединение вместо N — дедупликация между вкладками

Классический повод для SharedWorker: приложение, которое пользователи держат открытым в нескольких вкладках одновременно — админка, дашборд, почтовый клиент. Если у каждой вкладки свой WebSocket до сервера или свой тяжёлый локальный кэш, который приходится прогревать заново, это и лишняя нагрузка на бэкенд (N соединений от одного и того же пользователя вместо одного), и потенциальная рассинхронизация состояния между вкладками.

ts Copy
// realtime.worker.ts
import { defineWorkerHandler } from 'vue-worker-kit/worker'

let socket: WebSocket | null = null
let latestState: unknown = null

export default defineWorkerHandler((input: { subscribe: boolean }) => {
  if (input.subscribe && !socket) {
    socket = new WebSocket('wss://api.example.com/live')
    socket.onmessage = (e) => { latestState = JSON.parse(e.data) }
  }
  return latestState
})
ts Copy
const shared = useSharedWorker<typeof import('./realtime.worker')>(() =>
  new SharedWorker(new URL('./realtime.worker.ts', import.meta.url), { type: 'module' }),
)

const state = ref(await shared.run({ subscribe: true }))

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

2. Импорт большого файла с живым прогрессом по строкам

Мастер импорта CSV/Excel на тысячи строк — типичная фича админок и внутренних инструментов. Ждать полного разбора и валидации файла молча, с одним спиннером до самого конца, ощущается как зависание, особенно если часть строк невалидна, и об этом хочется узнать раньше, чем через минуту ожидания в конце всего процесса.

ts Copy
// import.worker.ts
export default defineWorkerHandler(async (rows: RawRow[], ctx) => {
  const valid: Row[] = []
  const errors: RowError[] = []
  for (let i = 0; i < rows.length; i += 200) {
    const batch = rows.slice(i, i + 200).map(validateRow)
    ctx.reportChunk(batch) // видно в интерфейсе сразу, не дожидаясь остальных строк файла
    for (const r of batch) (r.ok ? valid : errors).push(r as never)
  }
  return { valid, errors }
})
ts Copy
const { run, chunks } = useWorker<typeof import('./import.worker')>(factory, { streaming: true })

const processedRows = computed(() => chunks.value.flat().length)
const { valid, errors } = await run(rawRows)

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

3. Повторяющийся тяжёлый расчёт с мемоизацией

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

ts Copy
const { run } = useWorker<typeof import('./quote.worker')>(factory, {
  cache: { cache: 'lru', maxCacheSize: 50 },
  retries: 2,
  retryDelay: (attempt) => attempt * 500,
})

async function recalculate() {
  quote.value = await run({ items: cart.value, discountCode: discountCode.value })
}

Кэш живёт на уровне конкретного инстанса useWorker() в оперативной памяти вкладки — ничего не персистится и не расшаривается между вкладками (для этого как раз есть useSharedWorker() из сценария 1 выше), но для одной сессии одного пользователя, гоняющего фильтры туда-сюда, этого более чем достаточно, чтобы заметная часть повторных вызовов вообще не доходила до воркера.


Итого

0.2.0 — это не редизайн, а закрытие конкретных пробелов, которые проявились уже после того, как ядро пакета пожило какое-то время в реальном использовании: воркер, общий на все вкладки, потоковые чанки вместо ожидания целиком, кэш и ретраи с задержкой для обычных воркеров, прогрев заранее и отмена целой пачки в пуле одним сигналом. Каждая фича прошла один и тот же путь, что и в первой версии — не просто «должно работать по спецификации», а проверено вживую в браузере, включая пару багов, которые нашлись уже на финальном ревью, а не выдуманы задним числом для красивой истории. Заодно подтянута инфраструктура вокруг самого пакета: CI, который реально проверяет и пакет, и демо на каждый пуш, ноль известных уязвимостей в зависимостях вместо двадцати, и engines.node, который говорит правду про то, на чём пакет действительно собирается сегодня. Zero runtime dependencies по-прежнему в силе — vue остаётся единственной peer-зависимостью, как и в первой версии.

NPM: https://www.npmjs.com/package/vue-worker-kit
GitHub: https://github.com/macrulezru/vue-worker-kit

Читать далее

vue-image-kit 1.1.0: от art direction до собственного image-сервера

20.08.2026

В версии 1.1.0 vue-image-kit дорос с «удобного компонента для картинок» до полноценного стека: авто-детект 8 CDN, self-hosted сервер ресайза на замену CDN, Nuxt-модуль с реальным Nitro-роутом, инкрементальная сборка CLI и ещё десяток менее заметных, но полезных вещей. Ниже — подробный разбор того, что попало в релиз и почему сделано именно так.

Метки
vuevue-image-kitnuxtimage-optimizationopensource

@macrulez/vue-form-schema 0.2.2: типы из Zod, Nuxt-модуль и дискриминированные поля

21.08.2026

Новая версия @macrulez/vue-form-schema приносит сразу семь крупных возможностей: автовывод TypeScript-типов из Zod/Yup/Valibot, официальный Nuxt-модуль, три новые UI-темы, поддержку OpenAPI, маппинг серверных ошибок, плагин для Vue DevTools и дискриминированные схемы.

Метки
vuevue-form-schemanuxttypescriptopensource

vue-storage-kit 0.2.0: React-поддержка и пять новых возможностей

23.08.2026

vue-storage-kit больше не завязан на Vue — под капотом появилось общее реактивное ядро, на котором теперь работает и React-хук. А вместе с этим — HMAC-подпись данных, undo/redo с историей значений, троттлинг записи и автоматическое восстановление после переполнения квоты хранилища.

Метки
vuereacttypescriptfrontendopensource