Files

23 KiB
Raw Permalink Blame History

Как устроен 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, только память"]
  1. Уровень движка (StorageAdapter, memoryAdapter() или indexedDBAdapter({ dbName })) — хранит снапшоты результатов запросов и очередь мутаций. Без него движок работает так же, но всё исчезает при перезагрузке страницы.
  2. Уровень сущности (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