YouTube

Redux Devtools хорош для проверки состояния.

Но иногда было бы неплохо иметь возможность также проверять производное состояние.

Не рекомендуется помещать производное состояние в хранилище. Но мое решение не ставит его в магазин; он помещает его только в Redux Devtools, используя stateSanitizer вариант.


Началось с другой проблемы

Меня всегда раздражали селекторы. Синтаксис такой длинный. Я имею в виду, посмотрите на это:

const selector = createSelector(
  selectThing1,
  selectThing2,
  (thing1, thing2) => doCalculation(thing1, thing2),
);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Если бы нам не нужно было запоминать это, в React мы могли бы просто

const thing3 = doCalculation(thing1, thing2);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Почему оптимизация производительности должна так сильно влиять на наш код?

Итак, я хотел перепроектировать синтаксис для селекторов с нуля: если бы я мог волшебным образом получить самый идеальный синтаксис для создания селекторов, как бы он выглядел?

Разница в файле синтаксиса селектора

Слева: повторно выбрать. Справа: что у меня получилось
.

Во-первых, я подумал, что было бы неплохо, если бы я мог просто определить саму функцию, и чтобы то, к чему я обращаюсь, автоматически обнаруживалось и запоминалось для меня. Но как определить, когда код обращается к определенному состоянию, и присвоить ему дополнительное поведение? Ну, это был бы JavaScript Proxy. Итак, могу ли я определить свой селектор таким образом?

const selectors = new Proxy(...);
// ...
selectors.getThing3 = selectors.getThing2 + selectors.getThing1;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Как насчет TypeScript? И как мы можем надежно использовать этот селектор в будущих селекторах?

В итоге я остановился на шаблоне компоновщика с функцией, которую можно было бы вызывать снова и снова, примерно так:

const selectors = buildSelectors<string>({
  reverse: state => state.split('').reverse().join(''),
})({
  isPalendrome: s => s.reverse === s.state,
})({
  // etc...
})() // End
Войти в полноэкранный режим

Выйти из полноэкранного режима

selectors тогда это будет объект со всем, что в нем есть, и правильно напечатанный.

Это похоже на то, как Редукс Инструментарий а также NgRx/Сущность адаптеры состояния имеют один большой объект с множеством утилит управления состоянием. Точный синтаксис, который я получил, был вдохновлен ими:

const adapter = buildAdapter<State>()()({ // Sorry
  reverse: s => s.state.split('').reverse().join(''),
})({
  isPalendrome: s => s.reverse === s.state,
})({
  // etc...
})() // End
Войти в полноэкранный режим

Выйти из полноэкранного режима

Итак, почему s? Ну а должен ли объект(s) передается в каждую функцию селектора с именем selectors, state или же selectorState? На самом деле это просто прокси, так что ничего из этого не имеет смысла. Итак, конвенция sтак как это короткая и единственная буква, которая объединяет все возможные значения.

Кроме того, поскольку мы рассматриваем селекторы так, как если бы они были просто состоянием, я назвал их существительными. Это гораздо менее неловко, чем такие глаголы, как getIsPalendrom: s => s.getReverse === s.getState.

Пока это приятно.

Но есть еще одна проблема со стандартным Redux/NgRx: возможность повторного использования. Со стандартным Redux/NgRx, если мы хотим повторно использовать селекторы для разных экземпляров состояния, мы не можем просто вызвать createSelector; мы должны создать создателей селекторов. Вот как это выглядит:

import { createSelector } from 'reselect'; // or whatever

// Need a function that returns the selector in order to be
// reusable and independently memoized:
const getSelectReverse = (selectState: (state: any) => string) =>
  createSelector(selectState, state => state.split('').reverse().join(''));

const getSelectIsPalendrome = (selectState: (state: any) => string) =>
  createSelector(
    selectState,
    getSelectReverse(selectState),
    (state, reverse) => state === reverse
  );

// ...
// Before using for some specific state
const selectReverse = getSelectReverse(selectSpecificState);
const selectIsPalendrome = getSelectIsPalendrome(selectSpecificState);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Вау, это отстой.

Давайте еще раз посмотрим на синтаксис, который я считаю идеальным:

import { buildAdapter } from '@state-adapt/core';

const stringAdapter = buildAdapter<string>()()({
  reverse: s => s.state.split('').reverse().join(''),
})({
  isPalendrome: s => s.reverse === s.state,
})();
Войти в полноэкранный режим

Выйти из полноэкранного режима

Так-то лучше.

Но возможно ли это?


Сложная часть: реализация

Я реализовал этот синтаксис в своей библиотеке управления состоянием, адаптировать состояние. адаптировать состояние — это библиотека управления состоянием, основанная на шаблоне адаптера состояния, представленном NgRx/Сущность entityAdapter.

Реализация довольно проста. Для каждого нового селектора в каждом блоке селектора создайте новую функцию, которая будет

  • Вызовите функцию, определенную разработчиком, но передайте прокси вместо состояния
  • В прокси get обработчик, используйте имя селектора для вызова каждой фактической функции селектора, передавая state
  • Кэшировать результаты каждого селектора «вход»
  • Перед каждым запуском проверяйте, есть ли какие-либо изменения с селекторами ввода и их кэшированными значениями.

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

  something: s => s.thing1 || s.thing2 || s.thing3,
Войти в полноэкранный режим

Выйти из полноэкранного режима

а также s.thing1 всегда возвращал что-то правдивое, тогда не имело бы значения, что thing2 или же thing3 вернется, поэтому мы никогда не звоним им. При использовании традиционных запоминаемых селекторов все входные селекторы должны вызываться заранее. Это может привести к ненужным вызовам thing2 а также thing3, и если бы они когда-либо возвращали что-то другое, основную функцию выбора также нужно было бы запускать снова; они сами по себе являются ненужной работой, но они также могут вызвать ненужную работу в нашем новом селекторе. С прокси мы можем избежать всего этого!

Разве это не круто?


Проблема

Но на самом деле с этим есть большая проблема.

Каждая из этих селекторных функций создается в одном и том же контексте. Это означает, что когда наш прокси кэширует значения селектора ввода, он кэширует их при каждом использовании этого селектора. Поэтому, если мы попытаемся использовать селектор в двух совершенно разных состояниях, мемоизация произойдет только тогда, когда состояния окажутся одинаковыми, чего может никогда не произойти.

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

Первая возможность состояла в том, чтобы определить каждый блок селектора в функции:

const stringAdapter = buildAdapter<string>()()(() => ({
  reverse: s => s.state.split('').reverse().join(''),
}))(() => ({
  isPalendrome: s => s.reverse === s.state,
}))();
Войти в полноэкранный режим

Выйти из полноэкранного режима

Это позволило бы мне лениво определять блоки с контекстом и объектом кеша, предназначенным для каждого конкретного экземпляра состояния.

Но этот синтаксис противоречил другим функциям, которые я уже реализовал, поэтому я быстро перешел к чему-то еще более раздражающему:

const stringAdapter = () => 
  buildAdapter<string>()()({
    reverse: s => s.state.split('').reverse().join(''),
  })({
    isPalendrome: s => s.reverse === s.state,
  })();
Войти в полноэкранный режим

Выйти из полноэкранного режима

Теперь я мог лениво построить весь адаптер и выделить объект кеша для каждого конкретного экземпляра состояния.

Но что это за вещь? Создатель адаптера состояния? Должны ли мы раздражаться с именами? И Prettier настоял на том, чтобы определить его с отступом последней строки, чтобы это раздражало людей, использующих VSCode, таких как я, когда они нажимают Enter, и VSCode автоматически устанавливает отступ курсора на 1 уровень. Я ненавижу это.

Затем я попробовал кое-что еще: сделать так, чтобы каждый селектор был создателем селектора, подобно традиционным запоминаемым селекторам:

const stringAdapter = buildAdapter<string>()()({
  reverse: () => s => s.state.split('').reverse().join(''),
})({
  isPalendrome: () => s => s.reverse === s.state,
})();
Войти в полноэкранный режим

Выйти из полноэкранного режима

Я был очень доволен этим, но я действительно, В самом деле ненавижу лишний синтаксис, и мне просто больно заставлять всех использовать адаптировать состояние (все 10 из них) написать доп. () => тысячи раз. Не говоря уже о переломных изменениях в адаптировать состояниеAPI.

Но это приблизило меня к тому, что в итоге сработало.

Когда я хочу вызвать селектор для определенного фрагмента состояния, я передаю объект кеша в качестве второго аргумента. Для всех экземпляров состояния по-прежнему будет использоваться только одна функция селектора, но она использует переданный ей объект кеша и передает его вышеперечисленным функциям селектора.

Это означает, что мне нужен объект кеша, предназначенный для каждой части состояния. Затем, когда я использую адаптер состояния, я просто определяю новый объект со всеми теми же селекторами, но с объектом кеша, переданным во втором аргументе, поэтому новые селекторы просто нужно передать. state и неявно используется правильный объект кэша.

Но подождите… Теперь у нас есть большой объект кеша со всеми возвращаемыми значениями всех селекторов, используемых для части состояния… Это довольно удобно!


Селекторы в Redux Devtools!

Итак, вот как теперь мы можем добавить селекторы в Redux Devtools:

  1. Соберите кеш селекторов каждого редуктора в один гигантский глобальный кеш селекторов.
  2. Создайте сериализатор, который преобразует кеш в простой объект, который будет легко читать в Redux Devtools.
  3. Прикрепите объект глобального кеша к window объект
  4. Создайте настраиваемый санитайзер состояния для Redux Devtools, который обращается к объекту глобального кеша, сериализует его и добавляет в состояние как еще одно свойство.

Есть одно предостережение: поскольку селекторы оцениваются лениво, когда они отображаются в Redux Devtools, кэшированные значения будут основаны на предыдущем состоянии. Итак, я назвал их _prevSelectors в адаптировать состояние.


Использование этого с NgRx или Redux

Вы можете импортировать stateSanitizer из @state-adapt/core и используйте это с Редукс или же NgRx Cегодня:

import { stateSanitizer } from '@state-adapt/core';

// Redux
const store = createStore(
  rootReducer,
  window.__REDUX_DEVTOOLS_EXTENSION__?.({ stateSanitizer }),
);
// Or however you pass in the stateSanitizer with your flavor of Redux

// NgRx
    StoreDevtoolsModule.instrument({
      // ...
      stateSanitizer,
    }),
Войти в полноэкранный режим

Выйти из полноэкранного режима

Какие бы селекторы вы ни определяли, используя buildAdapter можно будет показать в Redux Devtools. Но есть дополнительная функция, которую нам нужно вызвать, чтобы связать селекторы с глобальным состоянием, называемая mapToSelectorsWithCache:

import { buildAdapter, createSelectorsCache, mapToSelectorsWithCache } from '@state-adapt/core';

// ...

const stateAdapter = buildAdapter<State>()()({
  // ...
})();

// ...

// Each state slice will be memoized independently:

const getState1 = (state: any) => state.childState1 as State;
export const cache1 = createSelectorsCache();
const state1Selectors = mapToSelectorsWithCache(
  stateAdapter.selectors,
  getState1,
  cache1,
);

const getState2 = (state: any) => state.childState2 as State;
export const cache2 = createSelectorsCache();
const state2Selectors = mapToSelectorsWithCache(
  stateAdapter.selectors,
  getState2,
  cache2,
);
Войти в полноэкранный режим

Выйти из полноэкранного режима

Это проще в адаптировать состояниекстати 🙂

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

import { createSelectorsCache, globalSelectorsCacheKey } from '@state-adapt/core';
import { cache1, reducer1, cache2, reducer2 } from './child-feature/reducer';

const cacheChildren = (window as any)[globalSelectorsCacheKey]?.__children;
cacheChildren.childState1 = cache1;
cacheChildren.childState2 = cache2;

export const reducer = combineReducers({
  childState1: reducer1,
  childState2: reducer2,
});
Войти в полноэкранный режим

Выйти из полноэкранного режима

Вот и все!

Вот рабочее демо-ремо, которое вы можете проверить: Демонстрация корзины


Объединенные селекторы

Все это работает, если у вас есть селекторы, которые выбирают только один фрагмент состояния. Что, если вы хотите объединить селекторы в нескольких срезах состояния?

Ну, в Редукс а также NgRx вы всегда можете просто использовать обычный селектор. Очевидно, он не будет использовать глобальный кеш, но в любом случае будет работать так, как ожидалось:

const selectAllArePalendromes = createSelector(
  state1Selectors.isPalendrome,
  state2Selectors.isPalendrome,
  (...palendromes) => palendromes.every(p => p),
);
Войти в полноэкранный режим

Выйти из полноэкранного режима

В адаптировать состояниекаждый reducer называется «умным магазином», и вы можете комбинировать состояние из нескольких умных магазинов следующим образом (пример из Angular):

store1 = adapt(['string1', 'racecar'], adapter);
store2 = adapt(['string2', 'racecar'], adapter);

allArePalendromes$ = joinStores({
  string1: this.store1,
  string2: this.store2,
})({
  allArePalendromes: s => s.string1IsPalendrome && s.string2IsPalendrome,
})().allArePalendromes$;
Войти в полноэкранный режим

Выйти из полноэкранного режима

Этот синтаксис не так минимален, как мог бы быть, но он отражает синтаксис функции с именем joinAdapters поэтому при необходимости можно легко реструктурировать состояние. Минимализм не всегда должен быть на первом месте.

Во всяком случае, с помощью некоторого забавного кода я смог заставить их отображаться и в Redux Devtools. Они отображаются как дочерние элементы обоих входных хранилищ, к сожалению, используя идентификатор кеша в качестве ключа вместо значимой строки. Но они хотя бы есть!

Объединенные селекторы


Вывод

Я не знал, что в конечном итоге добавлю селекторы в Redux Devtools, когда начал пытаться выяснить, как сделать селекторы в адаптерах многоразовыми и независимо кэшируемыми. Но я рад, что сделал! Селекторы содержат много важной бизнес-логики, и это еще один случай, когда мы потенциально можем сэкономить много времени, не устанавливая точки останова в коде всякий раз, когда селектор выводит что-то неожиданное.

надеюсь ты попробуешь buildAdapter и дайте мне обратную связь. адаптировать состояние становится очень близко к выпуску 1.0. Хотя сейчас он используется в десятках проектов и имеет автоматизированные тесты, охватывающие каждую обнаруженную на сегодняшний день ошибку, все же есть вероятность, что чего-то не хватает, что необходимо будет исправить до версии 1.0. Но это не должно занять больше пары недель.

И если вам нравится buildAdapterвы также можете попробовать использовать остальную часть адаптировать состояние на особенность. Если у вас есть адаптер, подключить его к смарт-магазину очень просто:

adapter = buildAdapter<State>(...);
store = adapt(['statePath', initialState], this.adapter);
state$ = store.state$; // Observable of store's state
Войти в полноэкранный режим

Выйти из полноэкранного режима

Так что попробуйте и дайте репо звезда, если вам это нравится!

Дайте мне знать, как это происходит!