prostore
Prostore - это библиотека для работы с данными во фронтенде,
вдохновленная apollo-client.
Apollo Client решает только задачи работы с API-запросами GraphQL,
в то время как Prostore позиционируется как общее расширяемое решение,
позволяющее также работать с чисто фронтендовыми данными.
В этом плане Prostore чем-то похож на mobx.
Prostore родился из необходимости быстрого решения частых задач
в SPA проектах, написанных на React с использованием React Hooks,
поэтому на данный момент код библиотеки ориентирован именно
на работу с React.
Эти задачи включают в себя:
-
Создание цепочек зависимостей по данным
-
Запрос данных с бекенда с автоматическим обновлением компонента
при их получении и обработкой индикатора загрузки и ошибок
-
Отправка данных на бекенд в виде императивных мутирующих операций,
с отслеживанием состояния запроса
-
Синхронизация данных с localStorage
Установка
yarn add @proscom/prostore rxjs
//
npm install --save @proscom/prostore rxjs
Сторы
Данные в Prostore хранятся в сторах. Каждый стор представляет собой
некое хранилище, выполняющее какую-то одну функцию. Например,
стором может быть состояние выполнения API-запроса, или данные
о текущем пользователе.
В основе Prostore лежит RxJS -
мощная библиотека для работы с Observable.
Observable это как EventEmitter, только с одним типом событий - обновление данных.
Но благодаря функциям-операторам из rxjs можно создавать цепочки
зависимостей одних Observable от других.
Поближе познакомиться с rxjs можно здесь.
Каждый стор в Prostore реализует следующий интерфейс:
export interface IStore<State> {
readonly state: State;
readonly state$: Observable<State>;
}
Это позволяет создавать из сторов цепочки зависимостей. Например,
при обновлении данных о текущем пользователе можно автоматически
перевыполнить API-запрос, зависящий от них.
Также в любой момент можно получить актуальное состояние стора.
BehaviorStore
Для удобства есть базовый класс BehaviorStore
у которого state$
представляет собой BehaviorSubject
из rxjs.
Расширив этот класс, можно создавать свои собственные сторы, у которых
работа с состоянием похожа на классовые компоненты в React. Например,
import { BehaviorStore } from '@proscom/prostore';
class UserStore extends BehaviorStore {
constructor() {
super({
user: null
});
}
updateUser(newUser) {
this.setState({ user: newUser });
}
}
При расширении BehaviorStore в конструктор базового класса надо
передать первоначальное состояние стора - любой JS объект.
Это должен быть именно объект. Состояние не может быть массивом или простым типом.
Поэтому если надо использова не-объект, то оберните его в объект, присвоив
какому-нибудь ключу:
super({
data: [1, 2, 3]
});
В любом месте этого класса (а также снаружи, но это не рекомендуется)
можно вызывать функцию this.setState
, которая принимает обновление
состояния, либо функцию обновления состояния (как в реакте).
При вызове this.setState
происходит одноуровневое слияние старого
состояния с новым (типа newState = {...oldState, ...changes}
).
Если же передана функция, то она сразу вызывается и в аргумент ей
передается текущее состояние, а вернуть она должна изменения.
Если нужно сбросить состояние целиком, например, чтобы удалить какие-то ключи,
можно воспользоваться более низкоуровневым вызовом this.state$.next(newState)
.
Создав такой стор, можно дальше подписаться на него стандартными
средствами rxjs:
const userStore = new UserStore();
const subscription = userStore.state$.subscribe((state) => {
console.log('state changed', state);
});
userStore.updateUser('Tester');
subscription.unsubscribe();
https://codesandbox.io/s/prostore-example-behavior-jomsr
Для удобства работы с подписками в React, смотри библиотеку
prostore-react
.
AsyncBehaviorStore
Иногда может быть полезно не применять изменения состояния стора
сразу, а отложить их до следующего цикла. Так например делает React
при работе с классовыми компонентами.
В Prostore есть класс AsyncBehaviorStore, который собирает все вызовы
this.setState
в текущем синхронном цикле и выполняет их все сразу
последовательно в следующем (с помощью setTimeout
).
Это может быть полезно, если стор может измениться более одного раза за
синхронный цикл. С точки зрения подписчиков, AsyncBehaviorStore изменится
только один раз, в то время как BehaviorStore вызовет своих подписчиков
при каждом изменении.
import { AsyncBehaviorStore } from '@proscom/prostore';
class AsyncUserStore extends AsyncBehaviorStore {
constructor() {
super({
firstName: null,
lastName: null
});
}
setFirstName(firstName) {
this.setState({ firstName });
}
setLastName(lastName) {
this.setState({ lastName });
}
}
const userStore = new AsyncUserStore();
userStore.state$.subscribe((state) => {
console.log('state changed', state);
});
userStore.setFirstName('first');
userStore.setLastName('last');
https://codesandbox.io/s/prostore-example-async-kwnns
RequestStore
RequestStore это более высокоуровневая абстракция, которая представляет
собой состояние какого-либо запроса. Запрос - это произвольная функция,
возможно асинхронная, которая превращает свои параметры variables
в результат data
. Например, эта функция может выполнять GET HTTP-запрос
с помощью fetch, передавая variables
как query-параметры, и сохранять
тело результата как data
.
Состояние RequestStore имеет следующий тип:
export interface IRequestState<Vars, Data> {
data: Data | null;
loading: boolean;
loaded: boolean;
error: any;
variables: Vars | null;
}
У RequestStore есть основной метод, который можно вызывать снаружи:
async function loadData(
variables: Vars,
options: any = {}
): Promise<IRequestState<Vars, Data>> {}
При вызове этой функции запускается выполнение нового запроса данных
с новыми variables. Функция завершается, когда запрос будет выполнен
успешно либо с ошибкой.
Для создания собственного стора надо расширить класс RequestStore
,
переопределив функцию
export type IObservableData<Data> = Promise<Data> | Observable<Data>;
function performRequest(
variables: Vars,
options: Options
): IObservableData<Data>;
Пример можно посмотреть на CodeSandbox:
https://codesandbox.io/s/prostore-example-request-h9641
В библиотеках prostore-apollo
и prostore-axios
доступны свои классы,
расширяющие RequestStore, реализующие GraphQL-запросы и обычные HTTP-запросы
соответственно.
При вызове конструктора RequestStore
необходимо передать аргумент типа:
export interface IRequestStoreParams<Vars, Data> {
initialData?: Data;
skipQuery?: ISkipQueryFn<Vars, Data>;
updateData?: IUpdateDataFn<Vars, Data>;
ssrId?: string;
}
export type ISkipQueryFn<Vars, Data> = (vars: Vars) => Data | null | undefined;
export type IUpdateDataFn<Vars, Data> = (
data: Data,
oldData: Data,
params: { store: any; variables: Vars; options: any }
) => Data;
Для удобства в качестве skipQuery
можно передать одну из двух
предопределенных функций:
import { skipIf, skipIfNull } from '@proscom/prostore';
new MyRequestStore({
skipQuery: skipIf((vars) => !vars, defaultData),
skipQuery: skipIfNull(defaultData)
});
Утилиты
SubscriptionManager
SubscriptionManager хранит список подписок и позволяет отменить все активные подписки вызовом одной функции.
Рекомендуется использовать его при взаимодействии сторов друг с другом. См. пример ниже в разделе
Общение между сторами.
Рецепты использования
Общение между сторами
При работе с глобальными сторами рекомендуемый способ организации взаимодействия между двумя сторами - передать один
из сторов в качестве аргумента в конструктор другого:
import { BehaviorStore, SubscriptionManager } from '@proscom/prostore';
interface IUserStoreArgs {
tokenStore: TokenStore;
}
interface IUserStoreState {
user: User;
}
class UserStore extends BehaviorStore<IUserStoreState> {
sub = new SubscriptionManager();
constructor({ tokenStore }: IUserStoreArgs) {
super({ user: null });
this.sub.subscribe(tokenStore.state$, this.handleTokenStoreChange);
}
destroy() {
this.sub.destroy();
}
handleTokenStoreChange = (tokenState) => {
this.setState({ user: tokenState.user });
};
}
const tokenStore = new TokenStore();
const userStore = new UserStore({ tokenStore });
Рекомендуется избегать циклической зависимости между сторами, но если она необходима, то можно передавать один
стор в другой динамически:
import { BehaviorStore, SubscriptionManager } from '@proscom/prostore';
class UserStore extends BehaviorStore<IUserStoreState> {
sub = new SubscriptionManager();
constructor() {
super({ user: null });
}
setTokenStore(tokenStore: TokenStore) {
this.sub.subscribe(tokenStore.state$, this.handleTokenStoreChange);
}
}
const userStore = new UserStore();
const tokenStore = new TokenStore({ userStore });
userStore.setTokenStore(tokenStore);
Шина событий
Избежать циклических зависимостей также можно, если использовать шину событий для взаимодействия между сторами.
Шина событий также поможет связать локальные сторы друг с другом, даже если у них нет прямых ссылок друг на друга.
Шина событий - это просто Observable, через который проходят какие-то события
(можно использовать что-то наподобие Actions из Redux).
Стор подписывается на события этого обзервабла и может каким-то образом реагировать на них.
import { Subject } from 'rxjs';
interface IStoreEvent {
type: string;
}
const EVENT_DATA_INCREMENT = 'EVENT_DATA_INCREMENT';
const storeEvents$ = new Subject<IStoreEvent>();
class DataStore extends BehaviorStore {
sub = new SubscriptionManager();
constructor() {
super({ value: 0 });
this.sub.subscribe(storeEvents$, this.handleStoreEvent);
}
destroy() {
this.sub.destroy();
}
handleStoreEvent = (event) => {
if (event.type === EVENT_DATA_INCREMENT) {
this.setState({ value: this.state.value + 1 });
}
};
}
const dataStore = new DataStore();
storeEvents$.next({ type: EVENT_DATA_INCREMENT });
Шину событий можно также использовать для императивного слабосвязанного взаимодействия между компонентами.
Используйте ее аккуратно, чтобы не запутывать код.
При использовании prostore-react
шину событий можно подключить в компонент с помощью хука useObservable
:
function MyComponent() {
useObservable(storeEvents$, (event) => {
});
}
Пагинация с дозагрузкой в RequestStore
В случаях когда нужна пагинация с дозагрузкой (например, бесконечный скролл с дозагрузкой)
можно использовать функцию updateData
.
Пример:
import { insertPaginatedSlice } from '@proscom/prostore';
const myStore = new MyRequestStore({
updateData: (data, oldData, params) => {
const page = params.variables.page;
const perPage = params.variables.perPage;
return insertPaginatedSlice(data, oldData, page, perPage);
}
});
let state;
state = await myStore.loadData({ page: 0, perPage: 2 });
state = await myStore.loadData({ page: 1, perPage: 2 });
См. пример в CodeSandbox