23 KiB
Как устроен vue-sync-engine
Это объяснение «на пальцах»: что происходит внутри библиотеки от вызова
useQuery() до перерисовки компонента. Для справочника по API см.
README.md — здесь фокус на механике и картинках.
Зачем он вообще нужен
В обычном SPA каждый компонент сам решает, откуда брать данные: сам
дёргает fetch, сам хранит результат в ref, сам решает, когда обновить.
Если один и тот же пост показан в двух местах экрана — либо оба компонента
независимо грузят его заново, либо после мутации один обновился, а второй
остался со старыми данными.
vue-sync-engine убирает эту проблему через одну идею: все данные живут
в одном месте, компоненты только на них подписываются. Само место —
не «дерево ответов API», а плоский нормализованный кэш сущностей, как
таблицы в базе данных (в духе Apollo / RTK Query), а не как в наивном
fetch-кэше, где один и тот же пользователь может быть продублирован
внутри трёх разных ответов запросов.
Главная идея одной картинкой
Библиотека всегда состоит из двух половин, даже если физически они работают в одном JS-потоке: вкладка (то, что видит пользователь) и QueryGraph — «мини-сервер», который решает, что и когда фетчить. Между ними — заменяемый транспорт.
flowchart TB
subgraph TAB["📄 Вкладка браузера"]
direction TB
UI["Vue-компонент"]
HOOKS["useQuery / useMutation /\nuseInfiniteQuery / useEntity"]
RUNTIME["TabRuntime"]
MIRROR["Mirror\n(entities + query-state, ShallowRef)"]
UI --> HOOKS --> RUNTIME
RUNTIME <--> MIRROR
MIRROR -.-> UI
end
TRANSPORT{{"Transport\nInline (queueMicrotask) или\nSharedWorker (MessagePort)"}}
subgraph GRAPH["⚙️ QueryGraph — «мини-сервер» (тот же поток или SharedWorker)"]
direction TB
NODES["QueryNode-ы:\nдедуп fetch-ей, staleTime/gcTime,\nentityRefs"]
QUEUE["Очередь мутаций:\noptimistic → persist → retry/rollback"]
STORE[("StorageAdapter\nIndexedDB / память")]
NODES --> STORE
QUEUE --> STORE
end
RUNTIME -->|"Subscribe / Unsubscribe\nMutate / FetchNextPage"| TRANSPORT
TRANSPORT -->|"QueryPatch / EntityPatch\nMutateResult"| RUNTIME
TRANSPORT --> NODES
TRANSPORT --> QUEUE
Ключевая мысль: вкладка никогда не фетчит данные сама. Она только
посылает «хочу подписаться на такой-то запрос» и получает в ответ поток
патчей. Реальный fetch() живёт только в QueryGraph.
Действующие лица
| Кто | Что это простыми словами |
|---|---|
| Entity | Тип сущности в кэше — «таблица» (post, user). Описывает только, как достать id у объекта, и опционально — где его персистить. |
| Query / InfiniteQuery | Описание запроса: как построить ключ кэша из аргументов, как зафетчить, как разложить ответ на сущности (normalize). |
| Mutation | Запись: fetch + опциональные optimistic (мгновенная правка) и onSuccess (правка после ответа) + invalidate (что перефетчить). |
| Mirror | Реактивный «слепок» на стороне вкладки: сущности по типам + состояния запросов. Единственное, что реально читают компоненты. |
| TabRuntime | Клиентская логика вкладки: подписки (с дедупом по хэшу ключа), их GC, отправка мутаций, разбор входящих патчей. |
| QueryGraph | Серверная логика: хранит QueryNode на каждый уникальный запрос, дедуплицирует fetch, гидрирует из storage, рассылает патчи всем подписчикам. |
| Transport | Канал сообщений между вкладкой и QueryGraph. Две реализации: Inline (тот же поток, батчинг через queueMicrotask) и SharedWorker (через MessagePort). |
| StorageAdapter / KeyedStore | Персистентность. Два независимых уровня — см. раздел ниже. |
Что происходит по шагам: от useQuery() до рендера
sequenceDiagram
autonumber
participant C as Компонент
participant TR as TabRuntime
participant M as Mirror
participant QG as QueryGraph
participant S as Storage
C->>TR: useQuery(usersQuery, args)
TR->>M: ensureQuery(subId) → status: idle
TR->>QG: Subscribe(subId, defName, args)
Note over QG: ensureNode() — находит или создаёт QueryNode по hash(key(args))
alt узел новый и в storage есть валидный снапшот
QG->>S: queries.read(key) + entities.readMany()
S-->>QG: QuerySnapshot + сущности
QG->>TR: EntityPatch (восстановленные сущности)
QG->>TR: QueryPatch(status: success, cached result)
end
opt данных нет или они устарели (age > staleTime)
QG->>TR: QueryPatch(status: pending)
TR->>M: applyQueryPatch → status: pending
M-->>C: isLoading = true
QG->>QG: fetch(args) → normalize(response)
QG->>S: сохранить QuerySnapshot + сущности
QG->>TR: EntityPatch (новые/обновлённые сущности)
QG->>TR: QueryPatch(status: success, result)
end
TR->>M: applyEntityPatches + applyQueryPatch
M-->>C: data / status обновились → компонент перерисовался
Важные детали, которые не видны в коде компонента:
- Дедупликация по ключу.
subscribeQueryхэшируетkey(args)(hashKey, стабильная сериализация — порядок полей объекта не важен) и ищет уже существующую подписку. Если два компонента одновременно вызвалиuseQuery(usersQuery, ...)с одинаковыми аргументами — будет одинQueryNodeи один fetch на двоих. isLoadingмигает и при фоновом рефетче. Если данные уже есть, но протухли (age > staleTime), QueryGraph сперва отдаёт кэш мгновенно, а затем всё равно переводит статус вpendingна время рефетча. Отдельного флагаisFetching/isRefetchingв библиотеке нет —isLoadingпокрывает оба случая: и первую загрузку, и фоновое обновление устаревших данных.- Протухание проверяется только при новой подписке. Нет ни
setInterval, ниvisibilitychange/focus-слушателей, которые бы сами дёргали рефетч фонового запроса. Пока подписчик один и не размонтировался — застоявшиеся данные просто лежат в кэше, пока кто-то не подпишется заново (например, при возврате на страницу) или пока их явно не инвалидирует мутация. - GC-окно на отписку.
onScopeDisposeвызываетrelease(), но реальная отписка (Unsubscribeв QueryGraph) откладывается наstaleSubGcMs(по умолчанию 5 c). Это защита от «мигания»: быстрый переход между вкладками/роутами не должен рвать подписку и гнать повторный fetch.
Нормализация: почему кэш плоский
normalize() в определении запроса разбирает ответ API на сущности
(что идёт в общий кэш) и result (тонкая структура из id, которая
хранится именно в этом запросе).
flowchart LR
RESP["Ответ API:\nPost {id:1, title, userId:5,\nauthor: {id:5, name...}}"] --> NORM["normalize(response)"]
NORM --> EPOST[("entities.post\n{1: {...}}")]
NORM --> EUSER[("entities.user\n{5: {...}}")]
NORM --> RES["result запроса\n{ids: [1]}"]
Почему это важнее, чем кажется: если пользователь 5 встречается ещё в
десяти других постах или в отдельном запросе users.list, это один и тот
же объект в entities.user, а не десять копий. useEntity(UserEntity, 5)
в любом компоненте — включая совсем не связанные с исходным запросом —
всегда прочитает актуальную версию. Оптимистичная мутация, которая
поправила имя пользователя, мгновенно видна везде, где он упомянут, без
ручной инвалидации каждого места.
Кэш и время жизни: staleTime и gcTime
У каждого QueryNode есть простой жизненный цикл:
stateDiagram-v2
[*] --> Idle
Idle --> Pending: первая подписка
Pending --> Success: fetch выполнен успешно
Pending --> Error: fetch завершился ошибкой
Success --> Pending: новая подписка застала данные протухшими (age > staleTime)
Success --> NoSubscribers: отписался последний подписчик
NoSubscribers --> Success: подписались снова до истечения gcTime
NoSubscribers --> [*]: gcTime истёк — узел и запись в storage удалены
Аналогия — молоко на полке магазина:
staleTime— «срок годности для доверия». Пока не истёк, новый покупатель (подписчик) берёт с полки без вопросов, fetch не идёт. По умолчанию 30 с.gcTime— «через сколько выбросить, если никто не берёт». Отсчёт идёт с момента, когда отписался последний подписчик. Если до истечения подписался кто-то новый — таймер просто отменяется. Если нет — узел и соответствующая запись вstorage.queriesудаляются насовсем. По умолчанию 5 мин.
Оба значения задаются дефолтами при бутстрапе (createEngine({ defaultStaleTime, defaultGcTime })) и переопределяются на уровне конкретного defineQuery(...).
Инвалидация
Мутация может явно перевести чужие узлы обратно в Pending, указав теги
или сами деф-объекты в invalidate. Узлы без активных подписчиков в этот
момент просто помечаются протухшими — рефетч случится при следующей
подписке, а не сразу.
Мутации: мгновенный UI + автоматический откат
Самая интересная часть. Когда вы вызываете mutate(input), происходит
следующее:
sequenceDiagram
autonumber
participant C as Компонент
participant TR as TabRuntime
participant QG as QueryGraph
participant Q as Очередь мутаций
participant API as Сервер
participant M as Mirror
C->>TR: mutate({id, title})
TR->>QG: Mutate(mutId, input)
QG->>Q: enqueue(mutId, input)
Q->>Q: optimistic(input, ctx) считает forward- и inverse-патчи
Q->>TR: EntityPatch (forward) — мгновенно
TR->>M: применить патч
M-->>C: UI обновился ДО ответа сервера
Q->>Q: persist в storage (переживёт перезагрузку страницы)
Q->>API: fetch(input)
alt успех
API-->>Q: ответ сервера
Q->>Q: onSuccess(ctx) + invalidate(tags)
Q->>TR: MutateResult(ok: true)
TR-->>C: mutateAsync() resolve
else сетевая ошибка, есть ещё попытки
API-->>Q: ошибка сети
Q->>Q: остаётся pending, drain() повторит попытку позже
else ошибка, попытки исчерпаны (или ошибка не сетевая)
Q->>Q: rollback — применить inverse-патчи в обратном порядке
Q->>TR: EntityPatch (inverse)
TR->>M: откатить патч
M-->>C: UI вернулся к прежнему состоянию
Q->>TR: MutateResult(ok: false)
TR-->>C: mutateAsync() reject
end
Что стоит понимать про optimistic:
optimistic: (input, ctx) => ctx.patchEntity(PostEntity, input.id, { title: input.title })
Вызывая patchEntity / upsertEntity / removeEntity, вы не пишете
rollback руками. Движок сам на лету считает инверсный патч (было —
стало наоборот) и применяет его автоматически, если мутация в итоге
провалилась.
Очередь мутаций — она же офлайн-режим
QueuedMutation пишется в storage.mutations до отправки запроса.
Это значит:
- если вкладку закрыть/обновить посреди мутации — при следующем старте движок подхватит незавершённые мутации из storage и продолжит попытки;
- retry идёт, только пока
navigator.onLineиattempts < maxRetries(по умолчанию 5); при возврате сети (online-событие) очередь сама запускаетdrain(); - сущности, тронутые ещё не завершённой мутацией, «запиниваются»
(
pinEntities) — фоновая сборка мусора сущностей (entityGc) их не тронет, пока мутация не разрешится.
Два режима движка
Один и тот же QueryGraph можно поднять либо в том же потоке, что и UI,
либо в SharedWorker, общем на все вкладки одного origin'а.
flowchart TB
subgraph INLINE["Inline — createEngine()"]
direction LR
T1["Вкладка"] --- QG1["QueryGraph\n(тот же JS-поток)"]
end
subgraph SHARED["SharedWorker — createTabEngine()"]
direction LR
T2["Вкладка 1"] -->|MessagePort| SW["SharedWorker\nодин QueryGraph на все вкладки"]
T3["Вкладка 2"] -->|MessagePort| SW
T4["Вкладка 3"] -->|MessagePort| SW
end
Inline (createEngine) |
SharedWorker (createTabEngine) |
|
|---|---|---|
| Кросс-таб синхронизация | нет | да, мгновенно |
| Дедупликация fetch | в пределах одной вкладки | глобально на все вкладки |
| IndexedDB | каждая вкладка открывает свою | один общий instance |
| Сложность подключения | минимальная | нужен отдельный worker-файл |
Код компонентов и определения (defineQuery и т.д.) не меняются вообще —
разница только в том, как собран TabRuntime на старте приложения.
Persistence: два независимых уровня
flowchart TB
QG["QueryGraph"] --> SA["StorageAdapter (уровень движка)"]
SA --> QS[("queries: QuerySnapshot\nрезультат + entityRefs")]
SA --> MQ[("mutations: QueuedMutation\nнезавершённые мутации")]
QG --> KS["KeyedStore (уровень сущности, опционально)"]
KS --> E1[("PostEntity → idbStore")]
KS --> E2["UserEntity → без storage, только память"]
- Уровень движка (
StorageAdapter,memoryAdapter()илиindexedDBAdapter({ dbName })) — хранит снапшоты результатов запросов и очередь мутаций. Без него движок работает так же, но всё исчезает при перезагрузке страницы. - Уровень сущности (
KeyedStoreвdefineEntity({ storage })) — каждый тип сущности сам решает, персистится ли он, независимо от остальных. В демоPostEntityживёт в IndexedDB, аUserEntity— только в памяти, специально для контраста.
Оба уровня работают вместе: снапшот запроса хранит только entityRefs
(ссылки {type, id}), а сами данные сущностей при гидрации подтягиваются
из своего KeyedStore. Если у типа сущности нет storage и её нет в
памяти воркера — гидрация признаётся неудачной, снапшот выбрасывается, и
при следующей подписке всё просто перефетчится заново.
Автодискавери определений через Vite-плагин
Вместо того чтобы руками собирать массивы entities/queries/mutations,
можно раскидать defineEntity/defineQuery/defineMutation по файлам
*.defs.ts и один раз подключить плагин:
syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })
Плагин сканирует файлы по glob-маске и собирает всё найденное в один
виртуальный модуль virtual:sync-engine-registry. Дедуп — по name: если
один и тот же деф случайно экспортирован из двух мест, плагин молча
оставит первый найденный.
Vue DevTools
installEngine(app, runtime) в dev-режиме сама подключает кастомную
панель «Sync Engine» с пятью узлами: Engine (дефолты, счётчики),
Queries (статус/tags/cache-метаданные по каждой подписке), Entities
(персистентные vs in-memory, список инстансов), Mutations (кольцевой
буфер последних 50) и Tabs (обнаружение других вкладок через отдельный
BroadcastChannel). В продакшене весь код вырезается через константу
__SYNC_ENGINE_DEV__.
Шпаргалка: что за что отвечает в коде
| Файл | Отвечает за |
|---|---|
createEngine.ts |
Точки входа: createEngine / createTabEngine / bootstrapWorker / installEngine |
define.ts |
Фабрики defineEntity / defineQuery / defineInfiniteQuery / defineMutation, Object.freeze |
tab/mirror.ts |
Реактивный кэш вкладки: сущности по типам + состояния запросов, ShallowRef на каждую сущность отдельно |
tab/runtime.ts |
TabRuntime: дедуп подписок по хэшу ключа, GC-таймер отписки, mutate() |
worker/queryGraph.ts |
«Сервер»: QueryNode-ы, дедуп fetch-ей, гидрация из storage, инвалидация, entity-refcounting |
worker/mutationQueue.ts |
Очередь мутаций: optimistic → persist → retry → rollback |
core/patches.ts |
applyPatch + автогенерация инверсных патчей для rollback |
core/queryKey.ts |
hashKey() — стабильная сериализация ключа (порядок полей объекта не важен) |
transport/InlineTransport.ts |
Транспорт в одном потоке, батчинг через queueMicrotask |
transport/SharedWorkerTransport.ts |
Транспорт через MessagePort поверх SharedWorker |
adapters/ |
memoryAdapter / indexedDBAdapter (движок), idbStore / memoryStore / noopStore (сущности) |
composables/ |
Vue-обвязка: useQuery / useMutation / useInfiniteQuery / useEntity / useEngine |