UNPKG

@qtpy/state-management-observable

Version:

1. [Основная идея и архитектура](#основная-идея-и-архитектура) 1.1. [Прозрачная реактивность](#прозрачная-реактивность) 1.2. [Поддержка типизированных строковых ключей](#поддержка-типизированных-строковых-ключей) 1.3. [Подписки с «гранул

1,496 lines (1,060 loc) 69.5 kB
# Пример и краткая документация по `ObservableStore` 1. [Основная идея и архитектура](#основная-идея-и-архитектура) 1.1. [Прозрачная реактивность](#прозрачная-реактивность) 1.2. [Поддержка типизированных строковых ключей](#поддержка-типизированных-строковых-ключей) 1.3. [Подписки с «гранулярностью» путей](#подписки-с-гранулярностью-путей) 1.4. [Кэш-ключи (cacheKeys)](#кэш-ключи-cachekeys) 1.5. [Middleware-подход](#middleware-подход) 1.6. [Batching-обёртка](#batching-обёртка) 1.7. [Асинхронные обновления](#асинхронные-обновления) 1.8. [История изменений (undo/redo)](#история-изменений-undoredo) 1.9. [Интеграция с любым фреймворком](#интеграция-с-любым-фреймворком) 2. [Пример создания основного store (с middleware)](#пример-создания-основного-store-с-middleware) 2.1 [Что делает `DepthPath` и зачем он нужен](#что-делает-DepthPath-и-зачем-он-нужен) 2.2 [Что такое `Accessor` и зачем он нужен](#что-такое-Accessor-и-зачем-он-нужен) 2.3 [Что такое `CacheKeys` и зачем они нужен](#что-такое-CacheKeys-и-зачем-они-нужен) 3. [API обертки ssrStore](#api-обертки-ssrstore) 3.1. [`snapshot()`](#snapshot) 3.2. [`getSerializedStore(type)`](#getserializedstoretype) 3.3. [`getSSRStoreId()`](#getssrstoreid) 3.4. [`hydrate()`](#hydrated) 3.5. [`hydrateWithDocument(delay?, callback?)`](#hydratewithdocumentdelay-callback) 3.6. [`getIsSSR`](#getisssr) 3.7. [`updateSSR`](#updatessr) 4. [API createObservableStore](#api-createobservablestore) 4.1. [`store.subscribe(callback, cacheKeys?)`](#storesubscribecallback-cachekeys) 4.2. [`store.subscribeToPath(pathOrAccessor, callback, options?)`](#storesubscribetopathpathoraccessor-callback-options) 4.3. [`store.invalidate(cacheKey)`](#storeinvalidatecachekey) 4.4. [`store.get(pathOrAccessor)`](#storegetpathoraccessor) 4.5. [`store.update(pathOrAccessor, valueOrFn, options)`](#storeupdatepathoraccessor-valueorfn-options) 4.6. [`store.resolveValue(pathOrAccessor, valueOrFn)`](#storeresolvevaluepathoraccessor-valueorfn) 4.7. [`store.resolvePath(pathOrAccessor)`](#storeresolvepathpathoraccessor) 4.8 [`store.getRawStore()`](#storegetrawstore) 4.9 [`store.setRawStore()`](#storegetrawstore) 4.10 [`store.invalidateAll()`](#storeinvalidateall) 5. [Асинхронные обновления](#асинхронные-обновления) 5.1. [`store.asyncUpdate(pathOrAccessor, asyncUpdater, options?)`](#storeasyncupdatepathoraccessor-asyncupdater-options) 5.2. [`store.cancelAsyncUpdates(pathOrAccessor?)`](#storecancelasyncupdatespathoraccessor) 5.3. [`store.debounced(callback, delay)`](#storedebouncedcallback-delay) 5.4. [`store.isAborted(pathOrAccessor)`](#storeisabortedpathoraccessor) 6. [Батчинг (`store.batch`)](#батчинг-storebatch) 7. [История (undo/redo)](#история-undoredo) 7.1. [`store.undo(pathOrAccessor, spliceIndices?)`](#storeundopathoraccessor-spliceindices) 7.2. [`store.redo(pathOrAccessor, spliceIndices?)`](#storeredopathoraccessor-spliceindices) 7.4. [`store.getUndo(pathOrAccessor, step)`](#storegetundopathoraccessor-step) 7.3. [`store.getRedo(pathOrAccessor, step)`](#storegetredopathoraccessor-step) 7.5. [`store.getHistory(pathOrAccessor)`](#storegetHistorypathoraccessor) 7.6. [`store.clearHistoryPath(pathOrAccessor, mode?, spliceIndices?)`](#storeclearhistorypathpathoraccessor-mode-spliceindices) 7.7. [`store.clearAllHistory()`](#storeclearAllHistory) 8. [Статистика и очистка](#статистика-и-очистка) 8.1. [`store.getMemoryStats()`](#storegetmemorystats) 8.2. [`store.clearStore()`](#storeclearstore) 9. [Промежуточная обработка (Middleware)](#промежуточная-обработка-middleware) 9.1. [Когда срабатывает middleware](#1-когда-срабатывает-middleware) 9.2. [Изменение `value` внутри middleware](#2-изменение-value-внутри-middleware) 9.3. [Блокировка изменения](#3-блокировка-изменения) 9.4. [Последовательность нескольких middleware](#4-последовательность-нескольких-middleware) 10. [Основные преимущества такого подхода](#основные-преимущества-такого-подхода) 11. [Вывод](#вывод) --- Ниже приведён пример реализации универсального реактивного стора (ObservableStore), написанного на TypeScript без привязки к конкретному фреймворку. Такой стор можно легко «подключить» в любом фронтенд-фреймворке (React, Vue, Svelte, Solid и т. д.) путём написания нескольких обёрток (адаптеров) поверх базового API. - Обертка под react: [@qtpy/state-management-react](https://www.npmjs.com/package/@qtpy/state-management-react) --- ## Основная идея и архитектура 1. #### Прозрачная реактивность Реактивность реализована через систему подписок и JavaScript Proxy: - **Система подписок**: Хранит подписчиков (`subscribers`, `pathSubscribers`) и уведомляет их об изменениях через `notifyInvalidate`. Работает независимо от прокси, используя `getRaw` и `setRaw`. - **Система прокси**: Перехватывает операции чтения (`get`), записи (`set`) и удаления, собирает зависимости в `trackedPaths` и инициирует обновления через `store.update`, уведомляя подписчиков. - **Взаимодействие**: Подписки независимы, прокси зависит от них для уведомлений. Middleware интегрируется в цепочку обновлений. 2. #### Поддержка типизированных строковых ключей - В ObservableStore реализована строго типизированная система строковых путей, которая позволяет: - безопасно указывать строки путей вроде "user.settings.theme" без риска ошибок типов; - получать автодополнение путей при использовании TypeScript; - обеспечивать валидацию пути и значения: если путь некорректен — TypeScript подскажет об ошибке на этапе компиляции; - поддерживать доступ к кортежам и массивам по индексам, например: "items.0" или "list.2.name". - пример: [ссылка на картинку](https://raw.githubusercontent.com/TheOnlyFastCoder2/QtPy-library/refs/heads/main/state-management/main/packages/observable/assets/picture_autocomplete.png) <img src="https://raw.githubusercontent.com/TheOnlyFastCoder2/QtPy-library/refs/heads/main/state-management/main/packages/observable/assets/picture_autocomplete.png" width="1200"/> 3. #### Подписки с «гранулярностью» путей - Можно подписаться на любое конкретное поле вложенного объекта, используя либо строку-путь (`"user.settings.theme"`), либо Accessor: `($, t) => $.user.settings.theme`. - При изменении именно этого поля подписчики получат уведомление. Изменения в других полях не затронут эту подписку. 4. #### Кэш-ключи (cacheKeys) - Позволяют группировать «логические зависимости» (например, вычисляемые свойства). - При вызове `store.invalidate(key)` все подписчики, передавшие этот `cacheKey` при подписке, будут уведомлены, даже если напрямую поле остался тем же. 5. #### Middleware-подход - При каждом `update` (или прямой записи через Proxy) можно выполнить цепочку middleware, чтобы логировать, валидировать или блокировать изменения. - Каждый middleware видит путь (`string`) и новое значение, может модифицировать или остановить дальнейшее распространение. 6. #### Batching-обёртка - Позволяет «склеивать» несколько изменений в одно уведомление. Это важно, чтобы не вызывать повторный ререндер UI при последовательных взаимосвязанных изменениях. 7. #### Асинхронные обновления - Метод `asyncUpdate(pathOrAccessor, asyncFn, options?)` позволяет выполнять асинхронные операции (fetch, таймеры, запросы) и автоматически отменять предыдущие, если они всё ещё в процессе (опция `abortPrevious`). 8. #### История изменений (undo/redo) - Для указанных в `customLimitsHistory` свойств сохраняется история изменений с заданным лимитом. - Методы `undo(pathOrAccessor)` и `redo(pathOrAccessor)` позволяют откатиться на предыдущие/следующие значения. 9. #### Интеграция с любым фреймворком - Базовое API стора не зависит от React/Vue и т. д. - Для React достаточно написать хук `useObservableStore`, который внутри использует `store.subscribeToPath` с Accessor’ом, и диспатчит обновление компонента. Аналогично для Vue/Svelte/Solid достаточно написать адаптер, который на изменение Proxy вызывает реактивное обновление. --- ## Пример создания основного `store` (с middleware) ```ts // ObservableStore.ts import { createObservableStore, Middleware } from '@qtpy/state-management-observable'; // 1) Определяем начальный interface : interface StoreState { user: { name: string; age: number; settings: { theme: string; locale: string; }; }; items: number[]; counter: number; } // 2) Определяем начальный стейт: const initialState = { user: { name: 'Alice', age: 30, settings: { theme: 'light', locale: 'ru', }, }, items: [1, 2, 3], counter: 0, }; // 3) Глубина тип поиска строковых путей type DepthPath = 3; // 3) Пример middleware: простой логгер перед и после update const loggerMiddleware: Middleware<StoreState, DepthPath> = (store, next) => { return (path, value) => { console.log(`[Logger] До обновления: путь="${path}", старое значение=`, store.get(path)); next(path, value); console.log(`[Logger] После обновления: путь="${path}", новое значение=`, store.get(path)); }; }; // 3) Создаём стор с middleware и ограничением истории: export const store = createObservableStore<StoreState, DepthPath>( initialState, [loggerMiddleware], // цепочка middleware { customLimitsHistory: [ // Для свойства counter сохраняем до 3 предыдущих состояний ['counter', 3], ['user.settings.locale', 2], ['items.2', 3], [($) => $.items[3], 3], [($) => $.items, 3], ], } ); ``` ## API `createObservableStore` ### `store.subscribe(callback, cacheKeys?)` Это метод глобальной подписки на любые изменения в состоянии, вне зависимости от затронутого поля. Он позволяет отслеживать все обновления стора. При указании массива `cacheKeys`, подписка будет срабатывать только при изменениях, затрагивающих указанные зависимости. Это удобно для логгирования, аналитики, синхронизации и системного поведения, которое должно реагировать на любые модификации состояния. - **Параметры:** 1. `callback: (state) => void` — вызывается после каждого «батча» изменений. 2. `cacheKeys?: string[]` — массив строковых ключей (cacheKey). Если указан, уведомление придёт только тогда, когда: - **Пример:** ```ts // Подписываемся на все апдейты: const unsubAll = store.subscribe((fullState) => { console.log('Весь стейт изменился:', fullState); }); // Подписка, опирающаяся на cacheKey "user.settings.theme": const unsubFiltered = store.subscribe( (fullState) => console.log('Тема пользователя:', fullState.user.settings.theme), ['user.settings.theme'] ); // Отписка: unsubAll(); unsubFiltered(); ``` --- ### `store.subscribeToPath(pathOrAccessor, callback, options?)` subscribeToPath позволяет подписаться на изменения конкретного поля в состоянии, указав путь как строку или Accessor-функцию. Срабатывает только при изменении указанного участка данных, обеспечивая точный контроль над реактивностью. - **Параметры:** 1. `pathOrAccessor` 2. `callback: (newValue) => void` — вызывается при изменении указанного пути. 3. `options?: { immediate?: boolean; cacheKeys?: string[] }` - `immediate: true` — сразу вызываем callback с текущим значением, даже до первого изменения. - `cacheKeys: string[]` — список cacheKeys; колбэк будет вызываться при этом событии, даже если путь не менялся напрямую (см. `store.invalidate`). - **Примеры:** ```ts // 1) Подписка на изменение user.name по строковому пути: const unsubName = store.subscribeToPath('user.name', (newName) => console.log('Имя пользователя:', newName), { immediate: true, }); // 2) Подписка на первый элемент массива items: // Здесь index может меняться динамически внутри Accessor через t(index) let idx = 0; const unsubFirstItem = store.subscribeToPath( ($, t) => $.items[t(idx)], // Accessor<any> (val) => console.log('Первый элемент массива:', val), { cacheKeys: ['counter'] } ); // 3) Отписка: unsubName(); unsubFirstItem(); ``` --- ### `store.invalidate(cacheKey)` Инвалидирует указанный строковый ключ (`cacheKey`). Всем глобальным подписчикам, которые при подписке передали этот ключ в `cacheKeys`, придёт уведомление (даже если напрямую значение по их пути не менялось). - **Пример:** ```ts // Если где-то в логике нужно форсировать оповещение по подписчикам, полагающимся на cacheKey: store.invalidate('user.settings.theme'); store.invalidate(($) => $.user.settings.scheme); ``` --- ### `store.get(pathOrAccessor)` Метод store.get(pathOrAccessor) используется для безопасного получения значения из стора — по строковому пути ("user.age") или Accessor-функции. В отличие от прямого доступа через $.myObject, он гарантирует корректное извлечение даже при работе с реактивными объектами, проксями и вложенными структурами. Это особенно важно,`чтобы избежать ошибок вроде Uncaught TypeError: Illegal invocation`, которые могут возникнуть при вызове нативных методов DOM или работы с объектами вне контекста. Метод также улучшает читаемость, типизацию и делает код предсказуемым при масштабировании. - **Пример:** ```ts const age = store.get('user.age'); // 30 console.log('Возраст:', age); // Пример с Accessor: читаем элемент массива по динамическому индексу let idx = 1; const firstItem = store.get(($, t) => $.items[t(idx)]); console.log('Второй элемент массива:', firstItem); // 2 ``` --- ### `store.update(pathOrAccessor, valueOrFn, options)` Метод для синхронного обновления значения в хранилище. Он принимает: - `pathOrAccessor`: строку типа `"user.age"` или Accessor-функцию - `valueOrFn`: новое значение или функцию `(cur) => next` - `options`: объект с параметрами, включая `keepQuiet` --- #### Механизм Snapshot перед/после обновления При каждом вызове `update()`: - Перед обновлением создаётся снимок (snapshot) текущей структуры объекта или массива без инстанцирования нового экземпляра. - Старое значение сохраняется в историю (до `maxHistoryLength`). - Запускаются middleware (в порядке регистрации). - Применяется фактическое обновление. - После обновления формируется новый снимок и сравниваются хеши старого и нового состояний, чтобы точно определить, что изменилось. - Подписчики уведомляются только при реальных отличиях в данных. Это позволяет: - Минимизировать лишние обновления UI - Обеспечить корректную работу undo/redo - Эффективно отследить изменения для дебага --- #### Поддержка мутаций массивов через Proxy Даже косвенные изменения через методы `push`, `splice`, `sort` и другие — отслеживаются: - Все обращения проходят через `store.$`, перехватываются `Proxy` - Изменения через методы → автоматически вызывают `store.update(...)` - Поддерживается история, middleware, нотификации Примеры: ```ts store.$.items.push(100); // вызовет уведомление store.$.items.splice(1, 2); // → нотификация по "items" store.$.user.name = 'Charlie'; // → нотификация по "user.name" ``` --- #### Использование `update()` — варианты 1. **По строковому пути**: ```ts store.update('user.age', 35); ``` 2. **Через функцию**: ```ts store.update('user.age', (cur) => cur + 1); ``` 3. **С `keepQuiet`** — чтобы не уведомлять подписчиков: ```ts store.update('user.age', 36, { keepQuiet: true }); store.update.quiet('user.age', 36); ``` 4. **Через Accessor с динамикой**: ```ts let idx = 2; store.update( ($, t) => $.items[t(idx)], (cur) => cur * 10 ); ``` 5. **Массив и сравнение snapshot**: ```ts store.update('items', (prev) => { return prev.reverse(); // вызовет уведомление }); store.update('items', (prev) => { return prev; // не вызовет — snapshot не изменился }); ``` --- ### `store.resolvePath(pathOrAccessor)` `resolvePath` преобразует путь к данным (строку вида `'a.b'` или функцию `$ => $.a.b`) в строковый формат для внутреннего использования в хранилище. Гарантирует валидность пути и соответствие глубине вложенности. **Может пригодиться в Middleware** - **Пример:** ```ts store.resolvePath('user.name'); // → 'user.name' store.resolvePath(($) => $.profile.age); // → 'profile.age' ``` --- ### `store.resolveValue(pathOrAccessor, valueOrFn)` Этот метод для безопасного "предпросмотра" обновления состояния. Он рассчитывает, каким будет итоговое значение после применения функции или прямого значения, не меняя данные в сторе. Это удобно, когда нужно сделать условную проверку, провести валидацию, сравнить с текущим значением или подготовить сложную логику обновления, не рискуя триггерить подписки или ререндеры. **Может пригодиться в Middleware** - **Пример:** ```ts const nextCounter = store.resolveValue('counter', (cur) => cur + 5); console.log('Будет следующий counter:', nextCounter); // Но store.get("counter") остаётся прежним. ``` --- ### `store.getRawStore()` `getRaw` возвращает текущее «сырое» состояние без прокси и без обёрток реактивности. Это полезно, если нужно напрямую получить **весь state как объект** и работать с ним без триггера подписчиков. - **Пример:** ```ts const state = store.getRaw(); console.log(state); // { count: 1, user: { name: "Ann" } } ``` --- ### `store.setRawStore(newState, options?)` полностью заменяет весь state на новый объект. При этом: - все асинхронные операции отменяются, - история undo/redo очищается, - отложенные (debounced) операции сбрасываются, - подписчики остаются и будут уведомлены о новом состоянии (если не указать `keepQuiet`). - **Пример:** ```ts store.setRaw({ count: 42, user: { name: 'Bob' } }); // Подписчики вызовутся с новым состоянием store.setRaw({ count: 100 }, { keepQuiet: true }); // Подписчики НЕ вызовутся ``` --- ### `store.invalidateAll()` `invalidateAll` форсированно запускает **все подписки** (и глобальные, и path-подписки), даже если состояние не изменилось. Это удобно, когда нужно «перепросчитать» все зависимости, например после полной перезагрузки стейта. - **Пример:** ```ts store.subscribe((s) => { console.log('sub:', s); }); store.invalidateAll(); // "sub:" { count: 42, user: { name: "Bob" } } ``` ## Асинхронные обновления ### `store.asyncUpdate(pathOrAccessor, asyncUpdater, options?)` Асинхронный метод обновления - полезно если нужно отменить предыдущий запрос. При включённой опции `abortPrevious: true`, все незавершённые обновления по тому же пути автоматически прерываются, исключая гонки данных и обеспечивая корректное состояние. Это удобно при вводе в поле, дебаунсе, сетевых запросах или при переключении контекста. - Если указан `options.abortPrevious: true`, предыдущий незавершённый запрос по тому же пути будет отменён при помощи `AbortController`. - **Пример:** ```ts // Загрузим список с сервера и запишем в $.items: await store.asyncUpdate( 'items', async (currentItems, signal) => { const response = await fetch('/api/items', { signal }); const data = await response.json(); return data.list; }, { abortPrevious: true } ); ``` --- ### `store.asyncUpdate.quiet(pathOrAccessor, asyncUpdater, options?)` Вариант `asyncUpdate`, который **не вызывает перерисовку компонентов и не активирует подписки**. Это удобно для фоновых обновлений или временных значений, которые не должны вызывать реакцию в UI. - **Пример:** ```ts // Временно устанавливаем $.status = 'loading', без триггера подписок await store.asyncUpdate.quiet( 'status', async (_, signal) => { await new Promise((resolve, reject) => { const timeout = setTimeout(resolve, 1000); signal.addEventListener('abort', () => { clearTimeout(timeout); reject(new DOMException('Aborted', 'AbortError')); }); }); return 'idle'; }, { abortPrevious: true } ); ``` --- ### `store.cancelAsyncUpdates(pathOrAccessor?)` Метод для отмены активных асинхронных обновлений, запущенных через asyncUpdate. Если передать конкретный путь, отменяются запросы только по нему; если вызвать без аргументов — завершатся все «висящие» обновления во всём сторе. Это помогает избежать конфликтов данных, гонок, или лишней нагрузки, особенно при переключении экрана, logout'е, или повторном вводе данных. - **Пример:** ```ts // Отменить все асинхронные обновления: store.cancelAsyncUpdates(); // Отменить только для пути "items": store.cancelAsyncUpdates('items'); ``` --- ### `store.debounced(callback, delay)` Метод debounced создаёт функцию с отложенным вызовом, которая будет выполнена только после паузы между последовательными вызовами. Это особенно полезно для асинхронных операций, таких как HTTP-запросы, где важно избегать лишней нагрузки и отменять устаревшие запросы. **Пример использования:** ```ts type DepthPath = 1; const debouncedFetchItems = store.debounced( (path: PathOrAccessor<StoreState, DepthPath>, userId: number) => { store.asyncUpdate( path, async (currentItems, signal) => { const response = await fetch(`/api/items?user=${userId}`, { signal }); const data = await response.json(); return data.list; }, { abortPrevious: true } ); }, 1000 // задержка 1 секунда ); // Использование debouncedFetchItems('items', 123); debouncedFetchItems('items', 456); debouncedFetchItems.cancel(); debouncedFetchItems(($) => $.items, 452363); ``` ### `store.isAborted(pathOrAccessor)` Проверяет, был ли прерван (`abort`) асинхронный апдейт по указанному пути или Accessor. Метод использует внутренний `AbortController`, связанный с этим путём. - Возвращает `true`, если для данного пути существует контроллер и его `signal.aborted === true`. - Возвращает `false`, если контроллера нет или обновление ещё не отменено. - **Пример:** ```ts // Запускаем асинхронное обновление const promise = store.asyncUpdate('counter', async (current, signal) => { await new Promise((r) => setTimeout(r, 1000)); return current + 1; }); // Прерываем по ключу 'counter' store.abort('counter'); console.log(store.isAborted('counter')); // true ``` --- ## Батчинг (`store.batch`) ### `store.batch(callback)` Позволяет объединить несколько изменений состояния в один реактивный блок, при этом уведомления подписчикам отправляются только после завершения всех операций. Это уменьшает количество лишних ререндеров, увеличивает производительность и делает логику обновлений более предсказуемой. Особенно полезно при комплексных изменениях, когда нужно обновить сразу несколько полей без промежуточных реакций. - **Примеры:** ```ts // 1) Через метод update: await store.batch(() => { store.update('user.name', 'Charlie'); store.update('user.age', (cur) => cur + 2); store.update('items.0', 100); store.update('items.0', 200); // два изменения одного пути }); // Подписчики получат одно уведомление: // - user.name = "Charlie" // - user.age = previous + 2 // - items.0 = 200 (только последнее) // 2) С прямыми присваиваниями: await store.batch(() => { store.$.user.name = 'Charlie'; store.$.user.age = 23; store.$.items[0] = 100; store.$.items[0] = 200; // два присваивания }); // Подписчики увидят изменения: // "user.name", "user.age", "items.0" — со значением 200 ``` --- ## История (undo/redo) История изменений (undo/redo) не отслеживается автоматически для всех полей состояния. Чтобы активировать историю для конкретных свойств, необходимо явно указать их в параметре customLimitsHistory при создании хранилища. ```ts const store = createObservableStore<AppState, DepthPath>(initialState, [], { customLimitsHistory: [ ['user.age', 5], // [path, лимит] [($) => $.items[0], 3], // [accessor, лимит] ], }); ``` - **Важно:** история не реагирует на косвенные изменения массивов через методы (например, `store.$.items.push(23)`), поэтому такие правки **не** попадают в стек `undo/redo`. ### `store.undo(pathOrAccessor, spliceIndices?)` Что делает: Откатывает (undo) последнее изменение по указанному пути или Accessor. Параметр `spliceIndices` (опционально) — кортеж `[start, deleteCount]`, задающий индексы для удаления элементов из undo-стека, чтобы ограничить историю изменений. Если предыдущая запись существует, восстанавливает её и возвращает `true`. Иначе возвращает `false`. - **Пример:** ```ts store.update('counter', 10); store.update('counter', 20); console.log(store.get('counter')); // 20 store.undo('counter'); // Откат к 10 console.log(store.get('counter')); // 10 store.undo('counter', [0, 1]); // Удаляет первую запись из undo-стека ``` --- ### `store.redo(pathOrAccessor, spliceIndices?)` Повторяет (redo) последнее отменённое изменение по указанному пути или Accessor. Параметр `spliceIndices` (опционально) — кортеж `[start, deleteCount]`, задающий индексы для удаления элементов из redo-стека. Если есть отменённое значение, применяет его и возвращает `true`. Иначе возвращает `false`. - **Пример:** ```ts store.update('counter', 10); store.update('counter', 20); store.undo('counter'); // Возвращает к 10 store.redo('counter'); // Возвращает к 20 console.log(store.get('counter')); // 20 store.redo('counter', [0, 1]); // Удаляет первую запись из redo-стека ``` --- Вот документация в таком же формате для методов `store.getUndo`, `store.getRedo` и `store.getHistory`: --- ### `store.getUndo(pathOrAccessor, step)` - **Что делает:** возвращает значение из undo-истории на указанное количество шагов назад по пути `pathOrAccessor`. - `step = 0` — текущее значение. - `step = 1` — предыдущее значение. - Если шаг выходит за границы undo-истории, возвращает `undefined`. - **Пример:** ```ts store.$.counter = 10; store.$.counter = 20; store.$.counter = 30; store.getUndo('counter', 0); // 30 store.getUndo('counter', 1); // 20 store.getUndo('counter', 2); // 10 store.getUndo('counter', 3); // undefined ``` --- ### `store.getRedo(pathOrAccessor, step)` - **Что делает:** возвращает значение из redo-истории на указанное количество шагов вперёд по пути `pathOrAccessor`. - `step = 0` — ближайшее значение для повтора. - `step = 1` — следующее за ним и т.д. - Если redo-история пуста или шаг выходит за границы, возвращает `undefined`. - **Пример:** ```ts store.update('counter', 10); store.update('counter', 20); store.undo('counter'); // возвращает к 10 store.undo('counter'); // возвращает к undefined store.getRedo('counter', 0); // 10 store.getRedo('counter', 1); // 20 store.getRedo('counter', 2); // undefined ``` --- ### `store.getHistory(pathOrAccessor)` - **Что делает:** возвращает полную историю изменений по указанному пути в виде объекта `{ undo, redo }`. - `undo` — массив значений, от самого первого до текущего. - `redo` — массив отменённых значений, доступных для повторного применения. - **Пример:** ```ts store.update('counter', 10); store.update('counter', 20); store.undo('counter'); const history = store.getHistory('counter'); console.log(history.undo); // [10] console.log(history.redo); // [20] ``` --- ### `store.clearHistoryPath(pathOrAccessor, mode?, spliceIndices?)` Очищает историю изменений (undo/redo) для указанного пути или Accessor в состоянии. Параметр mode (опционально) определяет, какую часть истории очищать: только `'undo'`, только `'redo'` или обе (по умолчанию) если указать `'all'`. Параметр `spliceIndices` (опционально) — кортеж `[start, deleteCount]`, задающий индексы для удаления элементов из указанного стека, позволяя выборочно очищать часть истории. Метод не затрагивает текущее значение в сторе. - **Пример:** ```ts store.update('user.age', 25); store.update('user.age', 30); store.clearHistoryPath('user.age'); // Очищает всю историю для user.age console.log(store.get('user.age')); // 30 (значение не изменилось) store.clearHistoryPath(($) => $.user.age, 'undo', [0, 1]); // Очищает первую запись в undo-стеке store.clearHistoryPath(($) => $.user.age, 'all', [0, 1]); // очищает первую запись у undo/redo ``` ### `store.clearAllHistory()` Этот метод для полного сброса всей истории изменений во всём сторе. Удаляет все сохранённые состояния undo/redo для всех полей одновременно. - **Пример:** ```ts store.clearAllHistory(); ``` ## Статистика и очистка ### `store.getMemoryStats()` - **Что делает:** возвращает объект с текущими статистическими данными: - `subscribersCount` — число глобальных подписчиков. - `pathSubscribersCount` — число подписок по конкретным путям/Accessor’ам. - `historyEntries` — список всех путей и длина их истории. - `activePathsCount` — число активных путей (за которыми кто-то следит). - `abortersCount` — Число активных асинхронных обновлений. - **Пример:** ```ts const stats = store.getMemoryStats(); console.log('Глобальных подписчиков:', stats.subscribersCount); console.log('Подписок по путям:', stats.pathSubscribersCount); console.log('Асинхронные обновления:', stats.abortersCount); console.log('История:', stats.historyEntries); ``` --- ### `store.clearStore()` - **Что делает:** полностью очищает хранилище: - Удаляет все подписки (глобальные и по путям). - Отменяет все «висящие» асинхронные обновления. - Очищает историю. - **Пример:** ```ts // Когда стор больше не нужен: store.clearStore(); ``` --- ## Промежуточная обработка (Middleware) `Middleware` — это функции, которые «оборачивают» вызовы `store.update(...)` и дают возможность перехватывать (модифицировать, логировать, блокировать) запросы на изменение состояния. ### 1. Когда срабатывает middleware - Middleware вызываются **только** при: 1. вызове `store.update(...)`, или 2. прямой записи через Proxy (`store.$.some.key = newValue`). - Если обновление обойти Proxy (например, напрямую поменять внутренний «сырой» объект вне Proxy), middleware не запустятся. ```ts // Гарантированная активация middleware: store.update('user.name', 'Dmitry'); store.$.user.name = 'Dmitry'; // Proxy перехватывает и идёт через middleware // НЕ активирует middleware (не рекомендуется): // (внутренний «сырый» объект здесь не трогает Proxy) (store as any).rawState.user.name = 'Eve'; ``` --- ### 2. Изменение `value` внутри middleware Внутри middleware есть доступ к исходному `path` и `value`. Можно изменить `value` перед тем, как передать его дальше по цепочке, вызвав `next(path, newValue)`. ```ts const clampAgeMiddleware: Middleware<typeof initialState> = (store, next) => { return (path, value) => { const resVal = store.resolveValue(path, value); if (path === 'user.age') { // Ограничиваем возраст от 0 до 99: const clamped = Math.max(0, Math.min(99, resVal)); next(path, clamped); } else { next(path, value); } }; }; // Теперь при store.update("user.age", 150) реально попадёт 99. ``` --- ### 3. Блокировка изменения Если внутри middleware **не вызвать** `next(path, value)`, весь дальнейший вызов метода `update` «глохнет» — изменения не применяются, а последующие middleware не вызываются. ```ts const blockAgeMiddleware: Middleware<typeof initialState> = (store, next) => { return (path, value) => { if (path === 'user.age') { console.warn('[Middleware] Изменение user.age заблокировано'); // Не вызываем next → изменение не произойдёт return; } next(path, value); }; }; // Пробуем: store.update('user.age', 40); // Лог: [Middleware] Изменение user.age заблокировано // Возраст остаётся прежним store.update('user.name', 'Bob'); // Проходит нормально, потому что для "user.name" вызывается next. ``` --- ### 4. Последовательность нескольких middleware При создании стора можно передать массив middleware, например: `[mw1, mw2, mw3]`. Порядок вызова (после реверса) таков: 1. mw1 → вызывает mw2 2. mw2 → вызывает mw3 3. mw3 → вызывает «ядро» update Если на каком-то этапе `next` не вызывается, дальнейшие middleware и само ядро не получат управление, и стор не обновится. ```ts const mw1: Middleware<typeof initialState> = (store, next) => { return (path, value) => { const resVal = store.resolveValue(path, value); const resPath = store.resolvePath(path); console.log('[MW1] До', path, resPath, resVal); next(path, value); console.log('[MW1] После', path, store.get(path), resPath); }; }; const mw2: Middleware<typeof initialState> = (store, next) => { return (path, value) => { const resPath = store.resolvePath(path); console.log('[MW2] Проверяем', path); if (resPath === 'items.0') { console.log('[MW2] Блокируем изменение items.0'); return; // mw3 и ядро не выполнятся } next(path, value); }; }; const mw3: Middleware<typeof initialState> = (store, next) => { return (path, value) => { console.log('[MW3] Логика MW3'); next(path, value); }; }; const store = createObservableStore(initialState, [mw1, mw2, mw3]); // Пример: store.update('items.0', 999); // Лог: // [MW1] До items.0 999 // [MW2] Проверяем items.0 // [MW2] Блокируем изменение items.0 // → mw1 не продолжит после next, mw3 не вызовется, update не применится. store.update('user.name', 'Dmitry'); // Лог: // [MW1] До user.name Dmitry // [MW2] Проверяем user.name // [MW3] Логика MW3 // [MW1] После user.name Dmitry // → Значение применено. ``` --- ## API обертки ssrStore `ssrStore` — функция, расширяющая `ObservableStore` для поддержки серверного рендеринга (SSR). Она добавляет методы для сериализации состояния на сервере, гидратации на клиенте и безопасного управления асинхронными обновлениями. ### Сигнатура ```typescript function ssrStore<T extends object, D extends number = 0, S extends ObservableStore<T, D> = ObservableStore<T, D>>( store: S, ssrStoreId = 'ssrStoreId_default' ): S & { snapshot: () => Promise<T>; getSerializedStore: (type: 'window' | 'scriptTag' | 'serializedData') => Promise<string>; getSSRStoreId: () => string; hydrate: () => void; hydrateWithDocument: (delay?: number, callback?: () => void) => void; getIsSSR: () => boolean; updateSSR: ObservableStore<T, D>['asyncUpdate']; }; ``` ### Параметры - `store`: Экземпляр `ObservableStore`. - `ssrStoreId`: Уникальный идентификатор стора, чтобы выполнить гидратацию. --- ### Инициализация При передачи свойства `ssrStoreId`, stm оборачивается в ssrStore: ```tsx export const stGlobal = createObservableStore<State>({ counter: 0, items: [], }, [], {ssrStoreId: 'myStoreId'}); stGlobal.updateSSR('counter', async (c) => c + 1); ``` ## Методы ### `snapshot()` Возвращает текущее состояние стора после завершения всех асинхронных обновлений. Ждёт всю очередь асинхронных операций. - **Пример:** ```ts await stGlobal.updateSSR('counter', async (c) => c + 1); await stGlobal.updateSSR('counter', async (c) => c + 5); const snap = await stGlobal.snapshot(); console.log(snap.counter); // 6 ``` --- ### `getSerializedStore(type)` Сериализует состояние в JSON. - `type: 'window'` → Возвращает строку `window[ssrStoreId] = ${serializedData}`. - `type: 'scriptTag'` → Возвращает `<script>` тег с JSON-данными. - `type: 'serializedData'` → Возвращает только сериализованные данные. - **Пример:** ```ts const json = await stGlobal.getSerializedStore('serializedData'); // {"counter":0,"items":[]} const window = await stGlobal.getSerializedStore('window'); // window['myStoreID'] = {"counter":0,"items":[]}; const script = await stGlobal.getSerializedStore('scriptTag'); // <script id="myStoreID" type="application/json"> // window['myStoreID'] = {"counter":0,"items":[]}; // </script> ``` #### пример на сервере ```ts res.send(` <html> <body> <div id="root">${html}</div> ${await stGlobal.getSerializedStore('scriptTag')} </body> </html> `); ``` #### пример на ssr компонента ```ts <script id={stGlobal.getSSRStoreId()} dangerouslySetInnerHTML={{ __html: await stGlobal.getSerializedStore('window'), }} /> ``` --- ### `getSSRStoreId()` Возвращает идентификатор стора (`ssrStoreId`). - **Пример:** ```ts console.log(stGlobal.getSSRStoreId()); // "myStoreID" ``` --- ### `hydrate()` Выполняет гидратацию на клиенте: восстанавливает состояние из `window[ssrStoreId]` и удаляет временные данные из DOM и `window`. - **Пример:** ```ts // после того как сервер вставил данные в window.myStoreID stGlobal.hydrate(); console.log(stGlobal.get('counter')); // значение из server-side snapshot ``` --- ### `hydrateWithDocument(delay?, callback?)` Выполняет гидратацию на клиенте после события `window.onload`. - Можно указать задержку (мс). - Можно передать `callback`, который выполнится после гидратации. - **Пример:** ```ts // Без задержки stGlobal.hydrateWithDocument(); // С задержкой stGlobal.hydrateWithDocument(1000); // С колбэком (например, для React-рендера) stGlobal.hydrateWithDocument(0, () => { createRoot(document.getElementById('root')!).render(<App />); }); ``` --- ### `getIsSSR()` Возвращает `true`, если код выполняется на сервере, иначе `false`. - **Пример:** ```ts if (stGlobal.getIsSSR()) { console.log('Работаем на сервере'); } else { console.log('Работаем в браузере'); } ``` --- ### `updateSSR()` Асинхронный метод для обновления состояния с сохранением порядка выполнения. Все вызовы ставятся в очередь и выполняются **строго последовательно**. Сигнатура полностью повторяет `asyncUpdate` из `ObservableStore`, включая метод `updateSSR.quiet`. - `updateSSR(...)` обновляет состояние и уведомляет подписчиков. - `updateSSR.quiet(...)` обновляет состояние **без уведомления подписчиков**. - **Пример:** ```ts // Очередь обновлений — выполнятся по порядку await stGlobal.updateSSR('counter', async (c) => c + 1); await stGlobal.updateSSR('counter', async (c) => c + 10); // "quiet" версия await stGlobal.updateSSR.quiet('items', async (arr) => [...arr, 42]); console.log(stGlobal.get('counter')); // 11 console.log(stGlobal.get('items')); // [42] ``` #### Пример для react: ```typescript //SSRMain.tsx export default async function SSRMain() { return ( <> <Test1 /> <Test2 /> <Test3 /> </> ); } async function Test1() { await stGlobal.updateSSR(($) => $.counter, async () => 10); // 10 return <></>; } async function Test2() { await stGlobal.updateSSR(($) => $.counter, async (prevVal) => prevVal * 20); // 200 return <></>; } async function Test3() { return ( <head> <script id={stGlobal.getSSRStoreId()} dangerouslySetInnerHTML={{ __html: await stGlobal.getSerializedStore('window'), }} /> </head> ); } ``` --- ## Особенности - Отслеживает асинхронные обновления через `updateSSR`, выполняя их строго последовательно. - Поддерживает безопасную передачу состояния между сервером и клиентом. - Удаляет временные данные после гидратации. - `hydrateWithDocument` обеспечивает гидратацию после полной загрузки DOM. --- ## Что делает `DepthPath` и зачем он нужен `DepthPath` управляет тем, насколько глубоко TypeScript будет "раскрывать" вложенные свойства объекта, чтобы сгенерировать возможные строковые пути вида `"user.settings.locale"`, `"items.0"` и т. д. ``` По умолчанию стоит `DepthPath=0`. ``` **Пример:** ```tsx type DepthPath = 3; const store = createObservableStore<StoreState, DepthPath>({}); ``` --- #### ✅ `DepthPath = 0` - **Типы путей не вычисляются вообще.** - Все проверки типов путей (`SafePaths`, `PathExtract`, `PathOrAccessor`) становятся заглушками. - Значения принимаются по произвольным строкам, но: -**автокомплита нет**, -**типовая проверка путей и значений отключена**, - ✅ Это может быть удобно для моков, тестов, или свободной работы без ограничений. #### ⚠️ `DepthPath = 14` и выше - ⚠️ **Потенциально медленно**: генерация union-типов путей становится экспоненциальной. - ✅ Позволяет обращаться к глубоко вложенным путям, если они есть. - ❌ Но может: - привести к **замедлению или зависанию TypeScript/IDE (VSCode)**, - вызвать **ошибки "Type instantiation is excessively deep..."** при сложных типах. **Рекомендуется:** > Не использовать `DepthPath > 10`, **если только это не оправдано реально вложенными структурами.** ### Итог | `DepthPath` | Поведение | | ----------- | ----------------------------------------------------------------------- | | `0` | Заглушка: любые строки, нет проверки и автокомплита | | `1–7` | Оптимально: безопасные пути, разумная глубина | | `8–13` | Допустимо, но уже может тормозить IDE | | `14+` | Риск перегрузки компилятора, не рекомендуется без крайней необходимости | --- ## Что такое `Accessor` и зачем он нужен `Accessor<T, R>` — это **магическая вспомогательная функция**, которая позволяет безопасно обращаться к значениям в состоянии `store`, особенно когда путь к данным содержит **динамическую часть** (например, индекс массива или ID). --- ### Сигнатура ```ts export type Accessor<T, R = any> = ($: T, t: <K>(arg: K) => K) => R; ``` - `$` — сам `store` (его `proxy`-версия, например `store.$`) - `t(...)` — специальная обертка для **динамических значений** в пути На первый взгляд выглядит странно, но суть очень простая: - Ты пишешь обычный доступ к данным, используя `store.$`, например: ```ts store.$.items[2]; ``` - А теперь представь, что `2` — это переменная `index`, которая может меняться: ```ts const accessor = ($, t) => $.items[t(index)]; ``` 🔮 t(...) — просто обёртка, которая говорит системе: > «Эта часть пути — динамическая, её нужно сохранить как выражение, а потом превратить в строку.» --- ### Что происходит под капотом - `Accessor` никак не исполняется напрямую. - Вместо этого `store` вызывает `toString()` на `Accessor`, и получает строку вроде: ```ts 'items.5'; ``` - Это делается через **анализ тела функции и регулярки**: - Внутри `t(index)``5` - `$.items[t(index)]` превращается в `"items.5"` > 💡 Это очень лёгкий способ выразить динамические пути **без генерации миллионов типов и union'ов**, что делает работу в VSCode быстрой и безопасной. --- ### Примеры ```ts const index = 1; store.update(($, t) => $.items[t(index)], 999); // Аналогично: store.update("items.1", 999) ``` ```ts store.get(($, t) => $.user.settings[t('locale')]); // Аналогично: store.get("user.settings.locale") ``` ```ts store.subscribeToPath( ($, t) => $.items[t(dynamicIndex)], (val) => { console.log('Изменился элемент массива:', val); } ); ``` --- ### Почему это лучше, чем `store.update("items." + index, ...)` - ✅ Работает с автодополнением - ✅ Проверяется типами (`PathExtract`, `AssertValidPath`) - ✅ Не требует ручной склейки строк - ✅ Не нагружает IDE (в отличие от большого количества вложенных `union`-типов) --- ## ⚠️ На заметку - Функция `Accessor` используется **только как сигнатура и для парсинга**, она не вызывает `store.$` реально. - Не стоит использовать сложные условия внутри неё — только прямой доступ через `t(...)`. --- ## Что такое `CacheKeys` и зачем они нужен `cacheKeys` — это **специальное поле в состоянии `store`**, которое позволяет задавать **кастомные пути для кеширования или подписки**, в том числе с **автодополнением и типовой безопасностью**. --- ### Для чего это нужно Иногда нам нужно явно указать, **какие части состояния можно инвалидировать, подписывать или использовать в автодополнении**, особенно если они **формируются динамически** или зависят от логики. В таких случаях на помощь приходит `cacheKeys` — объект, в котором мы описываем возможные "виртуальные" пути, которые будут: - ✅ поддерживать **автодополнение** - ✅ работать с методами `store.invalidate(...)`, `store.subscribeToPath(...)` и др. - ✅ проверяться **типами** при использовании --- ### Пример ```ts interface StoreState { items: number[]; counter: number; cacheKeys?: { 'lol.items': number[]; // <- виртуальный путь }; } const initialState = { items: [1, 2, 3], counter: 0, }; export const store = createObservableStore<StoreState>(initialState, []); ``` Теперь можно использовать путь `"cacheKeys.lol.items.0"` в методах: ```ts store.invalidate('cacheKeys.lol.items.0'); ``` > ☝️ Это путь не к реальным данным, а к **виртуальному представлению**, которое вы определили в `cacheKeys`. --- ### Автодополнение Добавив ключ в `cacheKeys`, вы получаете: - ✅ Поддержку автодополнения в строках путей - ✅ Подсказки по индексам (если это массив) - ✅ Безопасность при работе с методами `invalidate`, `subscribeToPath`, `get` и др. --- ### Как это работает под капотом - `cacheKeys` — это просто поле в типе состояния, которое **не обязательно должно существовать в `initialState`**. - Оно используется **только для построения типа возможных путей**. - Вы можете задавать вложенные объекты и массивы, чтобы IDE и типы могли "понять", что доступно по строковому пути. --- ### Пример с подпиской ```ts store.subscribeToPath('cacheKeys.lol.items.2', (val) => { console.log('Элемент lol.items[2] изменился:', val); }); ``` --- ### Зачем использовать `cacheKeys`, если можно напрямую ```ts store.invalidate('lol.items.0'); // ❌ Может не существовать или не иметь автодополнения ``` **Вместо:** ```ts store.invalidate('cacheKeys.lol.items.0'); ``` `cacheKeys` позволяет: - ✅ Явно указать, какие ключи **имеют смысл** - ✅ Сделать систему **расширяемой и предсказуемой** - ✅ Избежать магии и багов, связанных с "плавающими" строками --- ## ⚠️ На заметку - `cacheKeys`**исключительно типовая конструкция**, не обязательная в `initialState` - Работает даже если `cacheKeys` нет в runtime, **главное — чтобы она была в интерфейсе `StoreState`** - Можно использовать вместе с `Accessor`, если путь динамический: ```ts const idx = 1; store.invalidate((t) => store.$['cacheKeys'].lol.items[t(idx)]); ``` ## Основные преимущества такого подхода 1. **Фреймворк-агностичность** - Ядро стора написано «чисто» на TypeScript, без зависимостей от React/Vue/Svelte. - Для каждого фреймворка достаточно написать адаптер (хук или плагин), который будет цепляться к `store.subscribeToPath` и диспатчить обновления UI. 2. **Точная гранулярность подписок** - Подписки могут работать по строковому пути или через Accessor<T>, где внутри Accessor можно использовать функцию `t(…)` для динамических индексов. - Подписчики получают уведомления только по тем полям, на которые они подписаны. 3. **Middleware и валидаторы** - Можно централизованно описать проверки/блокировки/трансформации значений до их записи. - Каждый middleware может модифицировать `value` или полностью отменить обновление. 4. **Асинхронная логика ввода-вывода** - `asyncUpdate` с опцией `abortPrevious` позволяет элегантно обрабатывать взаимодействие с сетью, отменяя прежние запросы, если они больше не актуальны. 5. **История, undo/redo** - Автоматический стек изменений для каждого пути. Удобно в UI для кнопок «отменить»/«вернуть». 6. **Batching** - Позволяет сгруппировать сразу несколько взаимосвязанных обновлений, чтобы подписчики получили единое уведомление, и UI не перерендеривался по каждому мелкому изменению. 7. **Полная поддержка TypeScript** - Тип `Accessor<T> = (t: (arg: any) => any) => T` обеспечивает автодополнение и статическую проверку при работе с вложенными путями. - Вызовы `store.get` и `store.update` с Accessor’ом позволяют точно указывать нужное свойство без хардкода строк. --- ## Вывод - **ObservableStore** — это универсальный реактивный стор, построенный на основе JavaScript Proxy, Accessor<T> для динамических путей, granular подписок и цепочек middleware. - Благодаря «чистому» ядру, написанному на TypeScript, его можно без изменений подключать в React, Vue 3, Svelte, Solid и другие среды: достаточно написать лёгкие адаптеры для подписки и рендеринга. - **Ключевые возможности**: 1. Поддержка динамических путей через `Accessor<T>`, где внутри можно вызвать `t(index)` для вычисления индекса. 2. Гранулярные подписки по точечному пути или Accessor’у. 3. Middleware для валидации и логирования. 4. Асинхронные обновления с отменой прошлых запросов (`asyncUpdate`). 5. История изменений (undo/redo) для каждого пути. 6. Бэчинг (`batch`) для группировки изменений. Если вам нужен лёгкий, быстро работающий, максимально гибкий реактивный стор с поддержкой динамических Accessor’ов, изложенный **ObservableStore** предоставит все механизмы «из коробки».