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-запрос, зависящий от них.
Также в любой момент можно получить актуальное состояние стора.
ValueStore
Это самый простой стор, который есть в prostore
.
Он представляет собой простую обертку над BehaviorSubject
.
const store = new ValueStore(5);
store.state$.subscribe((value) => {
console.log('changed', value);
});
store.setState(6);
Класс ValueStore
можно расширить, добавив в него дополнительные методы:
class IncrementStore extends ValueStore {
constructor() {
super(0);
}
increment() {
this.setState(this.state + 1);
}
decrement() {
this.setState(this.state - 1);
}
}
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 объект.
В отличие от ValueStore
, BehaviorStore
содержит дополнительную логику объединения ключей
и частичного обновления состояния (как в классовых React-компонентах).
Поэтому состоянием стора должен быть именно объект. Состояние не может быть массивом или простым типом.
Поэтому если надо использован не-объект, то оберните его в объект, присвоив
какому-нибудь ключу, либо используйте ValueStore
:
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);
События сторов
Можно использовать стор, как эмиттер произвольных событий.
Например, это может быть полезно при реализации фронтенда на компонентном фреймворке, когда нужно передать событие клика из одного компонента в другой, если они не связаны прямой иерархией (один не является родителем другого).
Такое событие нельзя надежно передать через подписку на состояние, так как состояние может быть изменено также другими событиями.
При подписке на состояние нельзя достоверно определить, каким событием это состояние было изменено, поэтому надо подписываться на само событие.
Например, пусть стор хранит число, которое можно увеличить или уменьшить. Уменьшение и увеличение значения стора - это событие.
Если мы хотим, чтобы действие выполнялось только при увеличении значения, то у нас есть два варианта:
- Подписаться на состояние и сравнивать его с предыдущим, определяя, увеличилось оно, или нет. Такая фильтрация изменений состояния с целью определения события возможна в этом конкретном случае, но не в произвольном.
- Подписаться непосредственно на событие увеличения значения.
Для того, чтобы создать событие, на которое можно подписаться, в сторе добавьте отдельным полем новый Subject
, соответствующий событию.
Можно также создать один общий Subject
на все события, как в примере ниже с шиной событий, только на уровне одного стора.
Но для примера рассмотрим вариант, когда на каждое событие создаётся отдельный Subject
.
import { Subject } from 'rxjs';
import { BehaviorStore } from '@proscom/prostore';
import { useStore, useObservableCallback } from '@proscom/prostore-react';
interface DataStoreState {
value: number;
}
class DataStore extends BehaviorStore<DataStoreState> {
onIncrement$ = new Subject<number>();
onDecrement$ = new Subject<number>();
constructor() {
super({ value: 0 });
}
increment() {
const value = this.state.value + 1;
this.setState({ value });
this.onIncrement$.next(value);
}
decrement() {
const value = this.state.value - 1;
this.setState({ value });
this.onDecrement$.next(value);
}
}
const dataStore = new DataStore();
function MyComponent() {
const [state, store] = useStore(dataStore);
console.log('component rendered', state.value);
useObservableCallback(store.onIncrement$, (value) => {
console.log('store incremented', value);
});
}
В примере выше при вызове dataStore.increment()
в консоль будет выведено следующее:
component rendered 1
store incremented 1
https://codesandbox.io/s/prostore-events-8z779
Шина событий
Избежать циклических зависимостей также можно, если использовать шину событий для взаимодействия между сторами.
Шина событий также поможет связать локальные сторы друг с другом, даже если у них нет прямых ссылок друг на друга.
Шина событий - это просто 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