@proscom/prostore
TypeScript icon, indicating that this package has built-in type declarations

0.2.12 • Public • Published

prostore

Prostore - это библиотека для работы с данными во фронтенде, вдохновленная apollo-client. Apollo Client решает только задачи работы с API-запросами GraphQL, в то время как Prostore позиционируется как общее расширяемое решение, позволяющее также работать с чисто фронтендовыми данными. В этом плане Prostore чем-то похож на mobx.

Prostore родился из необходимости быстрого решения частых задач в SPA проектах, написанных на React с использованием React Hooks, поэтому на данный момент код библиотеки ориентирован именно на работу с React.

Эти задачи включают в себя:

  1. Создание цепочек зависимостей по данным

  2. Запрос данных с бекенда с автоматическим обновлением компонента при их получении и обработкой индикатора загрузки и ошибок

  3. Отправка данных на бекенд в виде императивных мутирующих операций, с отслеживанием состояния запроса

  4. Синхронизация данных с 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();

// Выведет:
// state changed { user: null }
// state changed { user: 'Tester' }

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');

// Выведет:
// state changed: { firstName: null, lastName: null }
// state changed: { firstName: 'first', lastName: '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> {
  // Первоначальное значение data
  initialData?: Data;

  // Функция, позволяющая пропустить вызов performRequest
  // Эта функция должна вернуть undefined, если запрос не надо пропускать
  // Любое другое возвращенное значение будет сохранено как data
  skipQuery?: ISkipQueryFn<Vars, Data>;

  // Функция, которая позволяет не просто перезаписать data,
  // а объединить старую data с новой.
  // Это может быть полезно для запросов с пагинацией
  updateData?: IUpdateDataFn<Vars, Data>;

  // Идентификатор этого стора для передачи данных с сервера
  // при использовании Server-Side-Rendering
  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({
  // Если vars не удовлетворяет функции condition, то
  // запрос не вызывается и data = defaultData
  skipQuery: skipIf((vars) => !vars, defaultData),

  // или

  // Если vars равно null,
  // то запрос не вызывается и data = 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);
  }

  // destroy и handleTokenStoreChange аналогичны примеру выше
}

const userStore = new UserStore();
const tokenStore = new TokenStore({ userStore });
userStore.setTokenStore(tokenStore);

События сторов

Можно использовать стор, как эмиттер произвольных событий. Например, это может быть полезно при реализации фронтенда на компонентном фреймворке, когда нужно передать событие клика из одного компонента в другой, если они не связаны прямой иерархией (один не является родителем другого). Такое событие нельзя надежно передать через подписку на состояние, так как состояние может быть изменено также другими событиями. При подписке на состояние нельзя достоверно определить, каким событием это состояние было изменено, поэтому надо подписываться на само событие.

Например, пусть стор хранит число, которое можно увеличить или уменьшить. Уменьшение и увеличение значения стора - это событие. Если мы хотим, чтобы действие выполнялось только при увеличении значения, то у нас есть два варианта:

  1. Подписаться на состояние и сравнивать его с предыдущим, определяя, увеличилось оно, или нет. Такая фильтрация изменений состояния с целью определения события возможна в этом конкретном случае, но не в произвольном.
  2. Подписаться непосредственно на событие увеличения значения.

Для того, чтобы создать событие, на которое можно подписаться, в сторе добавьте отдельным полем новый 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';

// Subject - это особый вид Observable, который не дублирует переданные значения
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() {
  // Можно использовать глобальную переменную storeEvents$
  // но лучше пробросить её через контекст (useContext)
  useObservable(storeEvents$, (event) => {
    // Сделать что-то с storeEvents$
  });
}

Пагинация с дозагрузкой в 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 });
// data=[1,2] oldData=null params={ variables: { page: 0, perPage: 2 }, ... }
// state.data = [1,2]

state = await myStore.loadData({ page: 1, perPage: 2 });
// data=[3,4] oldData=[1,2] params={ variables: { page: 1, perPage: 2 }, ... }
// state.data = [1,2,3,4]

См. пример в CodeSandbox

Readme

Keywords

none

Package Sidebar

Install

npm i @proscom/prostore

Weekly Downloads

9

Version

0.2.12

License

ISC

Unpacked Size

176 kB

Total Files

128

Last publish

Collaborators

  • alexeyshilyaev
  • mayorandrew
  • sviryukov
  • a.derbenev