Пример и краткая документация по ObservableStore
Основная идея и архитектура
1.1. Прозрачная реактивность
1.2. Поддержка типизированных строковых ключей
1.3. Подписки с «гранулярностью» путей
1.4. Кэш-ключи (cacheKeys)
1.5. Middleware-подход
1.6. Batching-обёртка
1.7. Асинхронные обновления
1.8. История изменений (undo/redo)
1.9. Интеграция с любым фреймворкомПример создания основного store (с middleware)
2.1 Что делаетDepthPath
и зачем он нужен
2.2 Что такоеAccessor
и зачем он нужен
2.3 Что такоеCacheKeys
и зачем они нуженAPI обертки sstStore
3.1.snapshot()
3.2.getSerializedStore(type)
3.3.getSSRStoreId()
3.4.hydrate()
3.5.hydrateWithDocument(delay?, callback?)
3.6.getIsSSR
3.7.updateSSR
API createObservableStore
4.1.store.subscribe(callback, cacheKeys?)
4.2.store.subscribeToPath(pathOrAccessor, callback, options?)
4.3.store.invalidate(cacheKey)
4.4.store.get(pathOrAccessor)
4.5.store.update(pathOrAccessor, valueOrFn, options)
4.6.store.resolveValue(pathOrAccessor, valueOrFn)
4.7.store.resolvePath(pathOrAccessor)
4.8store.getRawStore()
4.9store.setRawStore()
4.10store.invalidateAll()
Асинхронные обновления
5.1.store.asyncUpdate(pathOrAccessor, asyncUpdater, options?)
5.2.store.cancelAsyncUpdates(pathOrAccessor?)
5.3.store.debounced(callback, delay)
5.4.store.isAborted(pathOrAccessor)
История (undo/redo)
7.1.store.undo(pathOrAccessor, spliceIndices?)
7.2.store.redo(pathOrAccessor, spliceIndices?)
7.4.store.getUndo(pathOrAccessor, step)
7.3.store.getRedo(pathOrAccessor, step)
7.5.store.getHistory(pathOrAccessor)
7.6.store.clearHistoryPath(pathOrAccessor, mode?, spliceIndices?)
7.7.store.clearAllHistory()
Статистика и очистка
8.1.store.getMemoryStats()
8.2.store.clearStore()
Промежуточная обработка (Middleware)
9.1. Когда срабатывает middleware
9.2. Изменениеvalue
внутри middleware
9.3. Блокировка изменения
9.4. Последовательность нескольких middleware
Ниже приведён пример реализации универсального реактивного стора (ObservableStore), написанного на TypeScript без привязки к конкретному фреймворку. Такой стор можно легко «подключить» в любом фронтенд-фреймворке (React, Vue, Svelte, Solid и т. д.) путём написания нескольких обёрток (адаптеров) поверх базового API.
- Обертка под react: @qtpy/state-management-react
Основная идея и архитектура
Прозрачная реактивность
Реактивность реализована через систему подписок и JavaScript Proxy:
- Система подписок: Хранит подписчиков (
subscribers
,pathSubscribers
) и уведомляет их об изменениях черезnotifyInvalidate
. Работает независимо от прокси, используяgetRaw
иsetRaw
. - Система прокси: Перехватывает операции чтения (
get
), записи (set
) и удаления, собирает зависимости вtrackedPaths
и инициирует обновления черезstore.update
, уведомляя подписчиков. - Взаимодействие: Подписки независимы, прокси зависит от них для уведомлений. Middleware интегрируется в цепочку обновлений.
- Система подписок: Хранит подписчиков (
Поддержка типизированных строковых ключей
- В ObservableStore реализована строго типизированная система строковых путей, которая позволяет:
- безопасно указывать строки путей вроде "user.settings.theme" без риска ошибок типов;
- получать автодополнение путей при использовании TypeScript;
- обеспечивать валидацию пути и значения: если путь некорректен — TypeScript подскажет об ошибке на этапе компиляции;
поддерживать доступ к кортежам и массивам по индексам, например: "items.0" или "list.2.name".
пример: ссылка на картинку
Подписки с «гранулярностью» путей
- Можно подписаться на любое конкретное поле вложенного объекта, используя либо строку-путь (
"user.settings.theme"
), либо Accessor:($, t) => $.user.settings.theme
. - При изменении именно этого поля подписчики получат уведомление. Изменения в других полях не затронут эту подписку.
- Можно подписаться на любое конкретное поле вложенного объекта, используя либо строку-путь (
Кэш-ключи (cacheKeys)
- Позволяют группировать «логические зависимости» (например, вычисляемые свойства).
- При вызове
store.invalidate(key)
все подписчики, передавшие этотcacheKey
при подписке, будут уведомлены, даже если напрямую поле остался тем же.
Middleware-подход
- При каждом
update
(или прямой записи через Proxy) можно выполнить цепочку middleware, чтобы логировать, валидировать или блокировать изменения. - Каждый middleware видит путь (
string
) и новое значение, может модифицировать или остановить дальнейшее распространение.
- При каждом
Batching-обёртка
- Позволяет «склеивать» несколько изменений в одно уведомление. Это важно, чтобы не вызывать повторный ререндер UI при последовательных взаимосвязанных изменениях.
Асинхронные обновления
- Метод
asyncUpdate(pathOrAccessor, asyncFn, options?)
позволяет выполнять асинхронные операции (fetch, таймеры, запросы) и автоматически отменять предыдущие, если они всё ещё в процессе (опцияabortPrevious
).
- Метод
История изменений (undo/redo)
- Для указанных в
customLimitsHistory
свойств сохраняется история изменений с заданным лимитом. - Методы
undo(pathOrAccessor)
иredo(pathOrAccessor)
позволяют откатиться на предыдущие/следующие значения.
- Для указанных в
Интеграция с любым фреймворком
- Базовое API стора не зависит от React/Vue и т. д.
- Для React достаточно написать хук
useObservableStore
, который внутри используетstore.subscribeToPath
с Accessor’ом, и диспатчит обновление компонента. Аналогично для Vue/Svelte/Solid достаточно написать адаптер, который на изменение Proxy вызывает реактивное обновление.
Пример создания основного store
(с middleware)
// 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
, подписка будет срабатывать только при изменениях, затрагивающих указанные зависимости. Это удобно для логгирования, аналитики, синхронизации и системного поведения, которое должно реагировать на любые модификации состояния.
Параметры:
callback: (state) => void
— вызывается после каждого «батча» изменений.cacheKeys?: string[]
— массив строковых ключей (cacheKey). Если указан, уведомление придёт только тогда, когда:
Пример:
// Подписываемся на все апдейты: 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-функцию. Срабатывает только при изменении указанного участка данных, обеспечивая точный контроль над реактивностью.
Параметры:
pathOrAccessor
callback: (newValue) => void
— вызывается при изменении указанного пути.options?: { immediate?: boolean; cacheKeys?: string[] }
—immediate: true
— сразу вызываем callback с текущим значением, даже до первого изменения.cacheKeys: string[]
— список cacheKeys; колбэк будет вызываться при этом событии, даже если путь не менялся напрямую (см.store.invalidate
).
Примеры:
// 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
, придёт уведомление (даже если напрямую значение по их пути не менялось).
Пример:
// Если где-то в логике нужно форсировать оповещение по подписчикам, полагающимся на 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 или работы с объектами вне контекста. Метод также улучшает читаемость, типизацию и делает код предсказуемым при масштабировании.
Пример:
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, нотификации
Примеры:
store.$.items.push(100); // вызовет уведомление
store.$.items.splice(1, 2); // → нотификация по "items"
store.$.user.name = 'Charlie'; // → нотификация по "user.name"
Использование update()
— варианты
- По строковому пути:
store.update('user.age', 35);
- Через функцию:
store.update('user.age', (cur) => cur + 1);
- С
keepQuiet
— чтобы не уведомлять подписчиков:
store.update('user.age', 36, { keepQuiet: true });
store.update.quiet('user.age', 36);
- Через Accessor с динамикой:
let idx = 2;
store.update(
($, t) => $.items[t(idx)],
(cur) => cur * 10
);
- Массив и сравнение snapshot:
store.update('items', (prev) => {
return prev.reverse(); // вызовет уведомление
});
store.update('items', (prev) => {
return prev; // не вызовет — snapshot не изменился
});
store.resolvePath(pathOrAccessor)
resolvePath
преобразует путь к данным (строку вида 'a.b'
или функцию $ => $.a.b
) в строковый формат для внутреннего использования в хранилище. Гарантирует валидность пути и соответствие глубине вложенности.
Может пригодиться в Middleware
- Пример:
store.resolvePath('user.name'); // → 'user.name'
store.resolvePath(($) => $.profile.age); // → 'profile.age'
store.resolveValue(pathOrAccessor, valueOrFn)
Этот метод для безопасного "предпросмотра" обновления состояния. Он рассчитывает, каким будет итоговое значение после применения функции или прямого значения, не меняя данные в сторе. Это удобно, когда нужно сделать условную проверку, провести валидацию, сравнить с текущим значением или подготовить сложную логику обновления, не рискуя триггерить подписки или ререндеры.
Может пригодиться в Middleware
- Пример:
const nextCounter = store.resolveValue('counter', (cur) => cur + 5);
console.log('Будет следующий counter:', nextCounter);
// Но store.get("counter") остаётся прежним.
store.getRawStore()
getRaw
возвращает текущее «сырое» состояние без прокси и без обёрток реактивности. Это полезно, если нужно напрямую получить весь state как объект и работать с ним без триггера подписчиков.
- Пример:
const state = store.getRaw();
console.log(state);
// { count: 1, user: { name: "Ann" } }
store.setRawStore(newState, options?)
полностью заменяет весь state на новый объект. При этом:
- все асинхронные операции отменяются,
- история undo/redo очищается,
- отложенные (debounced) операции сбрасываются,
подписчики остаются и будут уведомлены о новом состоянии (если не указать
keepQuiet
).Пример:
store.setRaw({ count: 42, user: { name: 'Bob' } });
// Подписчики вызовутся с новым состоянием
store.setRaw({ count: 100 }, { keepQuiet: true });
// Подписчики НЕ вызовутся
store.invalidateAll()
invalidateAll
форсированно запускает все подписки (и глобальные, и path-подписки), даже если состояние не изменилось.
Это удобно, когда нужно «перепросчитать» все зависимости, например после полной перезагрузки стейта.
- Пример:
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
.Пример:
// Загрузим список с сервера и запишем в $.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.
Пример:
// Временно устанавливаем $.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'е, или повторном вводе данных.
Пример:
// Отменить все асинхронные обновления: store.cancelAsyncUpdates(); // Отменить только для пути "items": store.cancelAsyncUpdates('items');
store.debounced(callback, delay)
Метод debounced создаёт функцию с отложенным вызовом, которая будет выполнена только после паузы между последовательными вызовами. Это особенно полезно для асинхронных операций, таких как HTTP-запросы, где важно избегать лишней нагрузки и отменять устаревшие запросы.
Пример использования:
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
, если контроллера нет или обновление ещё не отменено.Пример:
// Запускаем асинхронное обновление 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)
Позволяет объединить несколько изменений состояния в один реактивный блок, при этом уведомления подписчикам отправляются только после завершения всех операций. Это уменьшает количество лишних ререндеров, увеличивает производительность и делает логику обновлений более предсказуемой. Особенно полезно при комплексных изменениях, когда нужно обновить сразу несколько полей без промежуточных реакций.
Примеры:
// 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 при создании хранилища.
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
.
Пример:
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
.
Пример:
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
.
Пример:
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
.
Пример:
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
— массив отменённых значений, доступных для повторного применения.
Пример:
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]
, задающий индексы для удаления элементов из указанного стека, позволяя выборочно очищать часть истории. Метод не затрагивает текущее значение в сторе.
- Пример:
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 для всех полей одновременно.
- Пример:
store.clearAllHistory();
Статистика и очистка
store.getMemoryStats()
Что делает: возвращает объект с текущими статистическими данными:
subscribersCount
— число глобальных подписчиков.pathSubscribersCount
— число подписок по конкретным путям/Accessor’ам.historyEntries
— список всех путей и длина их истории.activePathsCount
— число активных путей (за которыми кто-то следит).abortersCount
— Число активных асинхронных обновлений.
Пример:
const stats = store.getMemoryStats(); console.log('Глобальных подписчиков:', stats.subscribersCount); console.log('Подписок по путям:', stats.pathSubscribersCount); console.log('Асинхронные обновления:', stats.abortersCount); console.log('История:', stats.historyEntries);
store.clearStore()
Что делает: полностью очищает хранилище:
- Удаляет все подписки (глобальные и по путям).
- Отменяет все «висящие» асинхронные обновления.
- Очищает историю.
Пример:
// Когда стор больше не нужен: store.clearStore();
Промежуточная обработка (Middleware)
Middleware
— это функции, которые «оборачивают» вызовы store.update(...)
и дают возможность перехватывать (модифицировать, логировать, блокировать) запросы на изменение состояния.
1. Когда срабатывает middleware
Middleware вызываются только при:
- вызове
store.update(...)
, или - прямой записи через Proxy (
store.$.some.key = newValue
).
- вызове
Если обновление обойти Proxy (например, напрямую поменять внутренний «сырой» объект вне Proxy), middleware не запустятся.
// Гарантированная активация 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)
.
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 не вызываются.
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]
. Порядок вызова (после реверса) таков:
- mw1 → вызывает mw2
- mw2 → вызывает mw3
- mw3 → вызывает «ядро» update
Если на каком-то этапе next
не вызывается, дальнейшие middleware и само ядро не получат управление, и стор не обновится.
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).
Она добавляет методы для сериализации состояния на сервере, гидратации на клиенте и безопасного управления асинхронными обновлениями.
Сигнатура
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:
export const stGlobal = createReactStore<State>({
counter: 0,
items: [],
}, [], {ssrStoreId: 'myStoreId'});
stGlobal.updateSSR('counter', async (c) => c + 1);
Методы
snapshot()
Возвращает текущее состояние стора после завершения всех асинхронных обновлений. Ждёт всю очередь асинхронных операций.
- Пример:
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'
→ Возвращает только сериализованные данные.Пример:
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>
пример на сервере
res.send(`
<html>
<body>
<div id="root">${html}</div>
${await stGlobal.getSerializedStore('scriptTag')}
</body>
</html>
`);
пример на ssr компонента
<script
id={stGlobal.getSSRStoreId()}
dangerouslySetInnerHTML={{
__html: await stGlobal.getSerializedStore('window'),
}}
/>
getSSRStoreId()
Возвращает идентификатор стора (ssrStoreId
).
- Пример:
console.log(stGlobal.getSSRStoreId()); // "myStoreID"
hydrate()
Выполняет гидратацию на клиенте: восстанавливает состояние из window[ssrStoreId]
и удаляет временные данные из DOM и window
.
- Пример:
// после того как сервер вставил данные в window.myStoreID
stGlobal.hydrate();
console.log(stGlobal.get('counter')); // значение из server-side snapshot
hydrateWithDocument(delay?, callback?)
Выполняет гидратацию на клиенте после события window.onload
.
- Можно указать задержку (мс).
- Можно передать
callback
, который выполнится после гидратации. - Пример:
// Без задержки
stGlobal.hydrateWithDocument();
// С задержкой
stGlobal.hydrateWithDocument(1000);
// С колбэком (например, для React-рендера)
stGlobal.hydrateWithDocument(0, () => {
createRoot(document.getElementById('root')!).render(<App />);
});
getIsSSR()
Возвращает true
, если код выполняется на сервере, иначе false
.
- Пример:
if (stGlobal.getIsSSR()) {
console.log('Работаем на сервере');
} else {
console.log('Работаем в браузере');
}
updateSSR()
Асинхронный метод для обновления состояния с сохранением порядка выполнения. Все вызовы ставятся в очередь и выполняются строго последовательно. Сигнатура полностью повторяет asyncUpdate
из ObservableStore
, включая метод updateSSR.quiet
.
updateSSR(...)
обновляет состояние и уведомляет подписчиков.updateSSR.quiet(...)
обновляет состояние без уведомления подписчиков.Пример:
// Очередь обновлений — выполнятся по порядку
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:
//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`.
Пример:
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).
Сигнатура
export type Accessor<T, R = any> = ($: T, t: <K>(arg: K) => K) => R;
$
— самstore
(егоproxy
-версия, напримерstore.$
)t(...)
— специальная обертка для динамических значений в пути
На первый взгляд выглядит странно, но суть очень простая:
Ты пишешь обычный доступ к данным, используя
store.$
, например:store.$.items[2];
А теперь представь, что
2
— это переменнаяindex
, которая может меняться:const accessor = ($, t) => $.items[t(index)];
🔮 t(...) — просто обёртка, которая говорит системе:
«Эта часть пути — динамическая, её нужно сохранить как выражение, а потом превратить в строку.»
Что происходит под капотом
Accessor
никак не исполняется напрямую.Вместо этого
store
вызываетtoString()
наAccessor
, и получает строку вроде:'items.5';
Это делается через анализ тела функции и регулярки:
- Внутри
t(index)
→5
$.items[t(index)]
превращается в"items.5"
- Внутри
💡 Это очень лёгкий способ выразить динамические пути без генерации миллионов типов и union'ов, что делает работу в VSCode быстрой и безопасной.
Примеры
const index = 1;
store.update(($, t) => $.items[t(index)], 999);
// Аналогично: store.update("items.1", 999)
store.get(($, t) => $.user.settings[t('locale')]);
// Аналогично: store.get("user.settings.locale")
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(...)
и др. - ✅ проверяться типами при использовании
Пример
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"
в методах:
store.invalidate('cacheKeys.lol.items.0');
☝️ Это путь не к реальным данным, а к виртуальному представлению, которое вы определили в
cacheKeys
.
Автодополнение
Добавив ключ в cacheKeys
, вы получаете:
- ✅ Поддержку автодополнения в строках путей
- ✅ Подсказки по индексам (если это массив)
- ✅ Безопасность при работе с методами
invalidate
,subscribeToPath
,get
и др.
Как это работает под капотом
cacheKeys
— это просто поле в типе состояния, которое не обязательно должно существовать вinitialState
.- Оно используется только для построения типа возможных путей.
- Вы можете задавать вложенные объекты и массивы, чтобы IDE и типы могли "понять", что доступно по строковому пути.
Пример с подпиской
store.subscribeToPath('cacheKeys.lol.items.2', (val) => {
console.log('Элемент lol.items[2] изменился:', val);
});
Зачем использовать cacheKeys
, если можно напрямую
store.invalidate('lol.items.0'); // ❌ Может не существовать или не иметь автодополнения
Вместо:
store.invalidate('cacheKeys.lol.items.0');
cacheKeys
позволяет:
- ✅ Явно указать, какие ключи имеют смысл
- ✅ Сделать систему расширяемой и предсказуемой
- ✅ Избежать магии и багов, связанных с "плавающими" строками
⚠️ На заметку
cacheKeys
— исключительно типовая конструкция, не обязательная вinitialState
- Работает даже если
cacheKeys
нет в runtime, главное — чтобы она была в интерфейсеStoreState
- Можно использовать вместе с
Accessor
, если путь динамический:
const idx = 1;
store.invalidate((t) => store.$['cacheKeys'].lol.items[t(idx)]);
Основные преимущества такого подхода
Фреймворк-агностичность
- Ядро стора написано «чисто» на TypeScript, без зависимостей от React/Vue/Svelte.
- Для каждого фреймворка достаточно написать адаптер (хук или плагин), который будет цепляться к
store.subscribeToPath
и диспатчить обновления UI.
Точная гранулярность подписок
- Подписки могут работать по строковому пути или через Accessor<T>, где внутри Accessor можно использовать функцию
t(…)
для динамических индексов. - Подписчики получают уведомления только по тем полям, на которые они подписаны.
- Подписки могут работать по строковому пути или через Accessor<T>, где внутри Accessor можно использовать функцию
Middleware и валидаторы
- Можно централизованно описать проверки/блокировки/трансформации значений до их записи.
- Каждый middleware может модифицировать
value
или полностью отменить обновление.
Асинхронная логика ввода-вывода
asyncUpdate
с опциейabortPrevious
позволяет элегантно обрабатывать взаимодействие с сетью, отменяя прежние запросы, если они больше не актуальны.
История, undo/redo
- Автоматический стек изменений для каждого пути. Удобно в UI для кнопок «отменить»/«вернуть».
Batching
- Позволяет сгруппировать сразу несколько взаимосвязанных обновлений, чтобы подписчики получили единое уведомление, и UI не перерендеривался по каждому мелкому изменению.
Полная поддержка 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 и другие среды: достаточно написать лёгкие адаптеры для подписки и рендеринга.
Ключевые возможности:
- Поддержка динамических путей через
Accessor<T>
, где внутри можно вызватьt(index)
для вычисления индекса. - Гранулярные подписки по точечному пути или Accessor’у.
- Middleware для валидации и логирования.
- Асинхронные обновления с отменой прошлых запросов (
asyncUpdate
). - История изменений (undo/redo) для каждого пути.
- Бэчинг (
batch
) для группировки изменений.
- Поддержка динамических путей через
Если вам нужен лёгкий, быстро работающий, максимально гибкий реактивный стор с поддержкой динамических Accessor’ов, изложенный ObservableStore предоставит все механизмы «из коробки».