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