Казалось бы зачем рассказывать о Redux в 2020ом году. Ведь есть столько замечательных альтернатив на поприще стейт-менеджеров (например). Ведь есть с десяток причин не любить Redux, о которых исписано немало статей, и прозвучало немало докладов. Однако кое-чего у него не отнять — на нём можно написать большой, функциональный, поддерживаемый и быстрый веб-сайт. Ниже я расскажу о приёмах, которые помогают это сделать с использованием react-redux. Интересно? Добро пожаловать под кат.


Оптимизируя redux-приложение


Дисклеймер. Для человека внимательно читавшего документацию срыва покровов не произойдет. Ваш капитан.


Лично я люблю Redux. За простоту и минимализм. Библиотека не делает никакой магии. Там где нет магии, нет возможности случайно что-то сломать, не зная, что именно ты сломал. Контроль возвращается в руки программиста, что с одной стороны развязывает руки, а с другой требует понимания при использовании. Речь ниже пойдет, как раз о таком "понимающем" использовании — приёмах, которые на первых парах требуют осознания и дисциплины, зато потом воспринимаются как что-то естественное.


Про store и state


Кратко про эти два понятия, чтобы не возникало путаницы.


Store в redux — это некоторый объект, который содержит state приложения и имеет несколько дополнительный функций, таких как функция взятия state, функция подписки на изменение в state, функция-диспатчер для событий. Иногда ниже я буду срываться на англицизм "стор"


State в redux-store согласно документации, как правило, объект с глубокой вложенностью, который, как правило, должен легко сериализовываться. State содержит данные приложения. Иногда ниже я буду срываться на англицизм "стейт"


Вводная про mapStateToProps


Функция connect, как следует из документации, является HOC-ом, предоставляющим подключение к store для компонента. Первым аргументом connect принимает функцию mapStateToProps. mapStateToProps обеспечивает подключение непосредственно к стейту приложения. Возвращать mapStateToProps обязан плоский объект (или функцию, но об этом позже).


Совет 1 Старайтесь избегать вложенных массивов и объектов в качестве результата mapStateToProps.

Иными словами, вместо


const mapStateToProps = () => {
  return {
    units: [1, 2, 3]
  }
}

пишите


const UNITS = [1, 2, 3];

const mapStateToProps = () => {
  return {
    units: UNITS
  }
}

Дело в том, что react-redux хоть и простая библиотека, но заботится об оптимизации. При всяком изменении стора mapStateToProps будет вызван. Результат исполнения mapStateToProps будет сравнён с предыдущим результатом исполнения функцией shallowEqual (по умолчанию). Т.о. содержимое объекта будет сравниваться поле за полем по ссылке или по значению, и если будет найдено различие, то подключённый к стору компонент будет перерисован.


Очевидно, что в предыдущем примере в первом случае на каждое изменение стора создается новый массив [1, 2, 3], который не пройдет сравнение по shallowEqual с предыдущим таким же массивом. Данные не поменялись, а перерисовка есть.


В общем и целом, если в return-объекте mapStateToProps присутствует вложенный объект, то это повод насторожиться. Пример выше, хоть и встречается в реальной жизни, всё-таки немного надуманный. Обычно вместо UNITS будет какое-нибудь selectUserUnits(state, userId). Что подводит нас концепции селекторов.


reselect


Т.к. state — это просто вложенный объект, то несложно осуществлять навигацию по нему через точку: state.todos[42].title. Его также несложно покрыть типами, от чего подобная навигация станет только надёжней. Однако, удобно закрывать прямой доступ к стейту. Непосредственно сам доступ осуществляется через специальные функции, которые в терминах redux принято называть селекторами.


reselect — это библиотека, которая примечательна тремя вещами. Во-первых, readme этой библиотеки в два десятка раз больше по объему, чем её код. Во-вторых, reselect предоставляет удобный способ создавать и комбинировать селекторы. В-третьих, эта библиотека позволяет оптимизировать количество вычислений (как следствие, скорость приложения) за счёт кеширования.


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

Тут вроде понятно. Условно, если вам надо выполнить сортировку, то имеет смысл закеширивать результат, а не выполнять вычисление на каждый вызов mapStateToProps.


Также reselect может быть полезен в контексте первого примера. Допустим, у нас есть следующие селекторы:


export const selectPriceWithDiscountByProductId = (state, id) => {
  const {price, discount} = state.product[id];

  return {price, discount};
};

export const selectTagsByProductId = (state, id) => state.product[id].tags || [];

Первый будет вызывать перерисовку всякий раз, когда его результат попадает в результаты mapStateToProps, потому что этот селектор всегда создает новый объект. Второй вызывает перерисовку для тех продуктов, у которых нет поля tags. И тот, и другой селектор имеет смысл мемоизировать с помощью reselect (как правильно напомнил faiwer в комментариях к посту — большое ему спасибо:) ).


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


const EMPTY_ARRAY = Immutable([]);

export const selectTagsByProductId = (state, id) => state.product[id].tags || EMPTY_ARRAY;

Совет 3 Не используйте reselect там, где не происходит вычислений.

Тут тоже понятно. Не надо лишний раз засорять память. const selectUserById = (state, id) => state.user[id] трудно ускорить, и кеширование тут не поможет


Кеширование у reselect довольно прямолинейное. Селектор, созданный функцией createSelector, запоминает последний результат исполнения. Тут надо бы поподробнее познакомиться с её интерфейсом


createSelector(...inputSelectors | [inputSelectors], resultFunc)

inputSelectors — селекторы, который будут вызваны над аргументами получившегося селектора. Их результаты будут переданы в resultFunc, которая вычислит результат. Например


// selectors.js

const selectUserTagById = (state, userId) => state.user[userId].tag;
const selectPictures = state => state.picture;

const selectRelatedPictureIds = createSelector(
  selectUserTagById,
  selectPictures,
  (tag, pictures) => (
     Object.values(pictures)
       .filter(picture => picture.tags.includes(tag))
       .map(picture => picture.id)
  )
)

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


Итак, как reselect понимает, что пора отдавать из кеша:


  • Если аргументы селектора не изменились (по shallowEqual), то отдаем из кеша. В нашем примере, если стейт и id пользователя не изменились, то reselect отдаст предыдущий результат
  • Если не изменились результаты inputSelectors (по shallowEqual), то отдаем из кеша. В нашем примере, если стейт всё-таки поменялся, то reselect вызовет selectUserTagById и selectPictures. Если они вернут неизменённую коллекцию pictures и тот же tag, то reselect отдаст предыдущий результат.

Совет 4 Без нужды не меняйте аргументы селекторов.

selectUser({user}) обеспечит регулярное вычисление как минимум для inputSelectors


А вот теперь нюанс. Память у reselect, как у рыбки: кеш длины 1. Это имеет как приятные следствия, так и неприятные. Протекание памяти нам не грозит, но мискеш получить очень легко. Допустим, у нас есть компонент RelatedPictures, который мы подключаем к стору таким образом


// RelatedPicturesContainer.js

import {connect} from 'react-redux';
import {RelatedPictures} from '../components';
import {selectRelatedPictureIds} from '../selectors';

const mapStateToProps = (state, {userId}) => {
  return {
    pictureIds: selectRelatedPictureIds(state, userId)
  }
};

export default connect(mapStateToProps)(RelatedPictures);

а используем таким


const RelatedPicturesList = ({userIds}) => (
  <div>
    {Array.isArray(userIds) && (
      userIds.map(id => <RelatedPictureContainer userId={id} />
    )}
  </div>
)

В этом примере на каждое изменение стора содержимое компонента RelatedPicturesList будет перерисовано полностью, потому что в каждом контейнере RelatedPictureContainer функция mapStateToProps возвращает новый массив pictureIds, потому что селектор selectRelatedPictureIds получает новый userId по мере отрисовки списка. Если, конечно, RelatedPictures не обладает своими оптимизациями в духе кастомного ShouldComponentUpdate, но это за рамками статьи.


Следующий совет идет прямиком из документации reselect


Совет 5 Используйте фабричные методы для селекторов.

Выше я уже упоминал, что mapStateToProps не обязана возвращать объект, она может быть фабрикой. При создании каждого контейнера react-redux смотрит, что возвращает соответствующий mapStateToProps, и если это функция, то она уже становится настоящим mapStateToProps


// selectors.js
const selectUserTagById = (state, id) => state.user[id].tag;
const selectPictures =  (state, id) => state.picture;

const createRelatedPictureIdsSelector = () => createSelector(
  selectUserTagById,
  selectPictures,
  (tag, pictures) => (
     Object.values(pictures)
       .filter(picture => picture.tags.includes(tag))
       .map(picture => picture.id)
  )
)

// RelatedPicturesContainer.js

import {connect} from 'react-redux';
import {RelatedPictures} from '../components';
import {createRelatedPictureIdsSelector} from '../selectors';

const createMapStateToProps = () => {
  const selectRelatedPictureIds = createRelatedPictureIdsSelector();

  return (state, {userId}) => {
    return {
      pictureIds: selectRelatedPictureIds(state, userId)
    };
  };
};

export default connect(createMapStateToProps)(RelatedPictures);

Теперь каждый RelatedPicturesContainer получил свою копию селектора selectRelatedPictureIds. У каждого из селекторов свой собственный кеш длиной 1. Селектор по-прежнему зависит от userId, но в силу особенностей отрисовки он его получает неизменным. В данном примере мы жертвуем памятью в угоду скорости вычисления. Тут важно, что при удалении контейнера из react-дерева, удалится и ссылка на объект в relatedPictureIds в памяти, а значит GC сможет всё отчистить.


Пример выше может показаться откровенной жестью. "ЧТО? Фабрики? Вы упоролись, что ли, на каждый чих такой бойлерплейт писать?". Специально для таких мыслей придумана замечательная библиотека re-reselect, которая на лету умеет создавать селекторы в зависимости от входящих аргументов. Всё ещё фабрика, но не в mapStateToProps


Совет 6 С осторожностью используйте re-reselect

За удобство приходится платить. Отдавая кеширование на откуп библиотеке, вы рискуете по неосторожности загадить память кешом. Пример выше, переделанный под использование ререселекта, в состоянии выкушать всю память на инстансе, если вы, например, используете Node.js и Server Side Rendering. Ведь в браузере у вас один пользователь, да и страничка живет недолго, а на сервер приходит много пользователей, и процесс там живет долго.


connect и его друзья


До сих пор советы относились к оптимизации выхлопа mapStateToProps. Однако, connect принимает бо'льшее количество аргументов.


connect(mapStateToProps, mapDispatchToProps, mergeProps, options)

Как я уже упоминал ранее, react-redux заботится об оптимизации. Например, если mapStateToProps принимает только один аргумент (state), то он не будет вызываться на изменения пропсов компонента. То же самое касается mapDispatchToProps.


Совет 7 Не декларируйте лишний раз второй аргумент в mapStateToProps и mapDispatchToProp и не прокидывайте лишних пропсов в компоненты обернутые connect'ом

Однако, mergeProps всегда будет вызван изменении пропсов. По умолчанию mergeProps внезапно делает простой мерж пропсов компонентов и результатов (предыдущих неизменных или новых) mapStateToProps и mapDispatchToProps. Соответственно, если изменились пропсы на входе в обертку, то по умолчанию изменятся и пропсы в компоненте под оберткой, что может привести к перерисовке. Бороться с этим можно разными методами. Например, часто встречается такая конструкция: connect(mapStateToProps, null, x => x).


В своей заботе react-redux идет ещё дальше. Например, если вместо mapStateToProps передать null, то компонент перестанет реагировать на изменение стейта (технически нет, но по сути да). Таким образом можно научить компонент диспатчить события и даже меняться от входных пропсов, но не обращать внимания на стейт. Если результат mapStateToProps совпадает с предыдущим (и пропсы не менялись), то mergeProps не будет вызван.


Наконец, четвертый аргумент options позволяет тонко подтюнить поведение connect. В частности переопределить понятие равенства новых и кешированных результатов первых трёх аргументов. По умолчанию новые и предыдущие пропсы компонента, новые и кешированные результаты mapStateToProps, mapDispatchToProps и mergeProps сравниваются по shallowEqual. Иногда удобно переопределить это поведение. Например, если нет возможности вернуть плоский объект из mapStateToProps.


Совет 8 Используйте четвертый аргумент connect, чтобы тонко настроить поведение контейнера. Будьте аккуратны также, как и при использовании shouldComponentUpdate — слишком жадное сравнение может пагубно сказаться на скорости работы приложения.

Заключение


Проблемы redux со скоростью никуда не делись. Под капотом всё также единая очередь подписчиков-контейнеров, которые оповещаются на любое изменение стейта. Однако при аккуратном использовании боль от этого недостатка можно сильно уменьшить, а то и вовсе забыть о ней.


В свою очередь React-redux — это библиотека не только про подключение компонентов к стору. Важно, что она предлагает некоторый паттерн организации кода (несколько похожий на MVVM). Следуя этому паттерну, всю возню с данными для представления можно унести в контейнеры, в том числе и работу по оптимизации компонентов. Как следствие, можно сделать свои компоненты максимально простыми, содержащими только логику отрисовки. Можно пойти дальше и полностью отказаться от shouldComponentUpdate и почти полностью от хуков (useRef слишком хорош).


Ну и в самом-самом заключении. Надеюсь, что прочитавший найдет хотя бы пару советов полезными — значит не зря писал. Всем бобра)