Risen.dev

Готовим селекторы

20 июля 2020 г.

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

// src/features/cart/module/selectors.js

// Селектор стейта корзины покупок
export const everything = state => state.cart

// Массив покупок, добавленных в корзину
export const items = state => everything(state).items

// Бонусы, которые будут получены за покупку
export const collectedBonuses = state => everything(state).collectedBonuses

/*
 * Суммарная стоимость покупок
 * Здесь используется функция createSelector из библиотеки reselect,
 * в данном случае она нужна чтобы не делать вычисления лишний раз
 * Подробнее такие селекторы будут рассмотрены ниже
 */
export const totalAmount = createSelector(
  items,
  items => items.reduce((acc, item) => {
    const { price, count } = item
    return acc + price * count
  }, 0)
)

Основные правила использования селекторов

Чтобы использовать селекторы наиболее эффективно, надо соблюдать несколько правил.

Инкапсуляция

Селектор позволяет скрыть в себе логику, ответственную за получение конкретной части данных.

Благодаря этому, внешний код (в том числе другие селекторы) может не задумываться, по какому пути нужно обратиться чтобы получить нужный срез стейта.

/*
 * 1 вариант
 * Здесь мы указываем полный путь до части данных
 * И если ключ под которым расположена корзина изменится, например на shoppingCart,
 * нам придется заменить путь в 3 местах сразу
 * При росте приложения количество мест может значительно увеличиться
 */
const everything = state => state.cart
const items = state => state.cart.items 
const collectedBonuses = state => state.cart.collectedBonuses

/*
 * 2 вариант
 * Здесь мы полагаемся на остальные селекторы,
 * указывая лишь путь от их результата до нужной части данных
 * Если ключ под которым расположена корзина изменится,
 * нам нужно будет поменять путь лишь в 1 месте - в селекторе everything
 * Таким образом каждый из селекторов имеет ограниченную ответственность,
 * благодаря чему рефакторить и поддерживать код становится в разы проще
 */
const everything = state => state.cart
const items = state => everything(state).items 
const collectedBonuses = state => everything(state).collectedBonuses

Реюзабельность

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

Именно поэтому не стоит объявлять селекторы по месту использования (например в компонентах). Это порождает те же проблемы, что были перечислены выше (в секции про инкапсуляцию).

/*
 * Плохо!
 * 1) Мы не сможем использовать этот селектор внутри других селекторов
 * 2) При каких-то изменениях структуры стора нам придется обновлять кучу компонентов
 */
useSelector(state => state.cart.items)

/*
 * Правильно!
 * Мы используем уже объявленный селектор,
 * и при изменениях в сторе нам ничего не придется здесь переписывать
 */
useSelector(cartSelectors.items)

Виды селекторов

  • Мемоизированные селекторы, созданные через createSelector из reselect
  • Селекторы без мемоизации, в которых мы ручками принимаем state и возвращаем нужные данные

Мемоизированные

По примеру из репозитория reselect может сложиться неправильное понимание того, как селекторы стоит использовать.

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

Вот основные ситуации для их применения.

Тяжелые вычисления

// только оплаченные элементы
const paidItems = createSelector(
  items,
  items => items.filter(filters.onlyPaid)
)

// только оплаченная сумма
const paidAmount = createSelector(
  paidItems,
  items => items.reduce(reducers.total, 0)
)

// общая сумма покупок
const totalAmount = createSelector(
  items,
  items => items.reduce(reducers.total, 0)
)

Нам не нужно заново рассчитывать что-либо, если входные параметры не изменились.

Любое изменение стейта заставляет все mapStateToProps и useSelector выполниться. В случае, если мы будем использовать не мемоизированную функцию, все тяжелые вычисления в селекторах будут произведены заново, вне зависимости от того, были ли входные данные селекторов задеты изменением в стейте.

А это означает, что если покупатель откроет/закроет какую-нибудь глобальную модалку, состояние которой лежит в редакс сторе, сумма покупок рассчитается заново, не смотря на то, что состояние модалок не имеет никакого отношения к корзине покупок.

Преобразование данных и композиция

const loadingState = createSelector(
  isLoading,
  isLoaded,
  isFailed,
  (isLoading, isLoaded, isFailed) => ({
    isLoading,
    isLoaded,
    isFailed
  })
)

Хоть данный селектор и не производит тяжелых вычислений, но он возвращает объект.

Если не выполнять мемоизацию, при каждом вызове такой селектор будет возвращать новый объект, в том числе когда все входные данные остались прежними. mapStateToProps и useSelector при неглубоком сравнении посчитают, что данные изменились, и компонент будет перерендерен.

Мемоизированный же селектор будет возвращать ссылку на старый объект, если входные данные не изменились, соответственно лишних ререндеров не будет.

Все это относится к любым значениям, имеющим ссылочный тип данных (массивы, инстансы Date, Map, Set и т.д.). Для простоты представителем подобных значений дальше будет выступать объект.

Возможен и другой случай:

const somePrimitive = createSelector(
  isA,
  isB,
  isC,
  (isA, isB, isC) => {
    return isA && isB && isC
  }
)

Этот селектор возвращает примитивное значение и не делает никаких тяжелых расчетов. Так что с точки зрения оптимизации нам не нужно здесь использовать createSelector. Более того, мемоизированный селектор будет выполнять больше вычислений и занимать больше памяти (хоть и ненамного).

А вот то же самое обычным селектором:

const somePrimitive = state => {
  return isA(state) && isB(state) && isC(state)
}

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

В таких случаях я отдаю предпочтение версии c createSelector, хоть она и уступает по производительности. В контексте всего приложения разница будет несущественная.

Обычные (без мемоизации)

const everything = state => state.cart

const items = state => everything(state).items

const calculation = state => everything(state).calculation

// композировать тоже можно
const bonuses = state => calculation(state).bonuses

Такие селекторы стоит использовать всегда, когда мы напрямую ссылаемся на данные из стора.

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

Мемоизированные селекторы в подобных случаях использовать не стоит — из-за проверки входных данных и сравнения их с предыдущими, такие селекторы будут медленнее (~ в 30 раз), а обьем занимаемой памяти увеличится, так как предыдущие входные данные нужно где-то хранить. Проблема с памятью не очень заметна, но становится вполне ощутима, когда входными данными является обьект с кучей данных.

Немного о useSelector

Может показаться, что useSelector позволяет не использовать мемоизированные селекторы, но это не так.

Первая причина - функция, переданная внутрь, выполняется при каждом изменении в сторе. Это означает, что различные тяжелые вычисления будут в любом случае выполнены, не важно изменилась ли вообще часть данных стейта, с которой функция работает. В отличие от мемоизированных селекторов, useSelector не может сравнить входящие аргументы функции (в данном случае он всего один - state, который и не выйдет сравнить без глубокого сравнения).

Вторая причина - нельзя отказываться от удобной возможности для композиции селекторов. Как уже обсуждалось здесь, без reselect такое не сделать без боли (либо понадобится писать свой хелпер).

Чтобы предотвратить ререндер, если не изменился результат селектора, useSelector, так же как и connect, сравнивает результаты текущего и прошлого выполнения функции (в случае с connect этой функцией является mapStateToProps). Отличие в том, что по дефолту useSelector производит простое сравнение с помощью ===, а connect выполняет shallowCompare (поверхностное сравнение, при котором сравниваются не сами обьекты, а их содержимое). При использовании useSelector подобное можно сделать с помощью 2 аргумента, которым можно передать свою функцию для сравнения, в том числе и shallowCompare.

Причина, по которой так сделано - атомарный подход при использовании хуков. Так же, как и в случае с this.state и хуком useState, вместо создания одного большого обьекта мы несколько раз вызываем хуки, ответственные за определенную часть данных. Поэтому shallowCompare изначально и не требуется.

// здесь просто сравнить не выйдет
// объект каждый раз новый, так что надо проверять содержимое
const mapStateToProps = state => ({
  one: selectors.one(state),
  two: selectors.two(state),
})

// а здесь для результатов можно использовать простое сравнение
const one = useSelector(selectors.one)
const two = useSelector(selectors.two)

То есть, при работе с useSelector, крайне нежелательно делать так:

/*
 * При совершенно любом изменении state, данный селектор будет вызван
 * А так как он каждый раз возвращает новый объект, наш компонент будет всегда ререндериться
 * Проблему можно решить с помощью передачи shallowCompare 2 аргументом, но это лишь костыль
 * Правильное решение - разбиение на несколько вызовов useSelector: первый получает one, второй two
 * (селектор написан прямо в компоненте только ради наглядности, не стоит так делать)
 */
useSelector(state => ({
  one: state.one,
  two: state.two
}))

Заключение

Нужно соблюдать основные правила:

  • Объявлять селекторы всего 1 раз (например на уровне модуля) и далее их использовать в остальных участках приложения.
  • Не рассчитывать на useSelector в плане оптимизаций. Он лишь предотвращает ререндер, если сравнение результата через === вернуло true.

Надо использовать обычные селекторы без мемоизации когда:

  • Нужно просто достать значение из стора
  • (не обязательно) Нужно сделать простую операцию над каким-то значением, при этом результатом этой операции является примитив

Надо использовать мемоизированные селекторы когда:

  • В селекторе есть тяжелые вычисления (сумма покупок, фильтрация, преобразование данных и так далее)
  • Результатом вызова селектора является значение с ссылочным типом данных