[Redux] Мой любимый устаревший вопрос на собеседовании
Время от времени мне приходится проводить собеседования. И сегодня я хочу поделиться моими любимыми вопросами на тему Redux. К сожалению, вопросы уже устарели, т.к. они касаются компонента высшего порядка connect, который активно заменяют на хуки. Но connect может уже и не сильно актуален, а принципы на которых он построен абсолютно не изменились (Данная статья является расшифровкой видео).
И так давайте перейдем к самим вопросам
После общего вопроса: “Что такое Redux?”. Я обычно спрашивал: “Какой первый параметр принимает connect?”. Тут все отвечают правильно: “mapStateToProps”. Но вот на вопрос, а что такое "mapStateToProps", какой это тип данных. Некоторые уже начинают отвечать неправильно.
Многие возможно немного волнуясь на собеседовании, начинают отвечать, что mapStateToProps - это объект. И после пары уточняющих вопросов, конечно исправляются на то, что mapStateToProps - это функция, которая первым параметром получает весь state и возвращает нужные компоненту данные.
Главный вопрос
Хорошо mapStateToProps это функция. Тогда давайте представим следующую ситуацию. Допустим у нас на странице несколько независимых блоков. Например один блок это список пользователей, другой блок список машин, третий блок это список квартир и так далее. В итоге на странице несколько абсолютно независимых блоков. Каждый блок обернут в свой connect и тянет только информацию своего блока.
// Users.js
const mapStateToProps = state => ({
users: state.users
})
export default connect(mapStateToProps)(Users)
// Cars.js
const mapStateToProps = state => ({
cars: state.cars
})
export default connect(mapStateToProps)(Cars)
// Apartments.js
const mapStateToProps = state => ({
apartments: state.apartments
})
export default connect(mapStateToProps)(Apartments)
Дальше, допустим, мы решили нажать кнопку удаления пользователя, которая обновляет store и удаляет из него пользователя.
И так, главный вопрос: "сколько разных функций mapStateToProps при этом вызовется?" Добавлю даже варианты ответа:
Осторожно ниже ответ!
3...
2...
1...
И правильный ответ...
Номер один. На этот вопрос многие дают ответ номер два.
По их мнению mapStateToProps вызывается только у компонента, контент которого обновился. И это звучит на первый взгляд очень логично. Но дальше можно задать уточняющий вопрос: “А как тогда redux понимает, какой именно нужно вызвать mapStateToProps?”. И этот вопрос, чаще всего, заставляет разработчиков задуматься, о том, что они возможно неправильно ответили на предыдущий вопрос.
Давайте вспомним как выглядит функция mapStateToProps.
const mapStateToProps = (state) => {
return {
users: state.users
}
}
Она принимает в качестве параметра переменную state, в которой лежит состояние всего стора. И только в этой функции мы определяем какие именно данные нужно передать в компонент. Это значит, что до вызова функции mapStateToProps, redux понятия не имеет, нужно рендерить компонент обернутый в connect или не нужно.
Если визуализировать картину это будет выглядеть следующим образом:
Action обновляет store. Store в свою очередь вызывает все зарегистрированные функции mapStateToProps, которые передают нужные компонентам данные в connect. И далее лишь один connect заставит обновиться компонент пользователей. Вот так работает наш пример.
Как это работает под капотом
Параметры mapStateToProps
Хорошо с mapStateToProps стало более менее понятно, как это работает. Но теперь хочется понять, как именно connect понимает нужно ему обновлять компонент, который он оборачивает или нет. Для того чтобы в этом разобраться мы изучим как работает connect под капотом.
Но перед этим, еще один мини вопрос (это для тех кто мнит себя знатаком инструментов). Мы знаем, что connect принимает несколько параметров, таких как mapStateToProps, mapDispatchToProps. Вопрос, сколько параметров можно передать в connect? Оборачиваемый компонент не считается за параметр. И так варианты ответа: 2? 3? 4? или 5?
Место на подумать...
3...
2...
1...
И правильный ответ connect принимает целых 4 параметра. Давайте посмотрим исходники и убедимся в этом (ссылка на исходники).
function connect(
mapStateToProps,
mapDispatchToProps,
mergeProps,
options
) {
// ...
}
Первые два нам хорошо известны. Далее менее популярный mergeProps, который позволяет нам сгруппировать данные полученные из первых двух функций.
И практически не используемый никем 4-ый параметр. И он представляет из себя объект, через который можно донастроить работу вашего connect.
{
// The `pure` option has been removed, so TS doesn't like us destructuring this to check its existence.
// @ts-ignore
pure,
areStatesEqual = strictEqual,
areOwnPropsEqual = shallowEqual,
areStatePropsEqual = shallowEqual,
areMergedPropsEqual = shallowEqual,
// use React's forwardRef to expose a ref of the wrapped component
forwardRef = false,
// the context consumer to use
context = ReactReduxContext,
}
Из интересных в нем настроек, я хотел бы обратить внимание на эту 4-ку.
{
areStatesEqual = strictEqual,
areOwnPropsEqual = shallowEqual,
areStatePropsEqual = shallowEqual,
areMergedPropsEqual = shallowEqual,
}
Поясню, что каждое поле означает. areStatesEqual - это функция, которая сравнивает изменился ли redux store. Поэтому по дефолту у него значение strictEqual, а не shallowEqual, т.к. нам нужно знать о любом изменении объекта, даже если это произошло где то глубоко.
Далее идет areOwnPropsEqual - это функция, которая сравнивает props которые передали в компонент обернутый в connect.
areStatePropsEqual - так же функция, которая сравнивает результаты выполнения функции mapStateToProps, собственно чаще всего самая судьбоносная функция, которая решит, нужно вам рендерить обернутый в connect компонент или нет.
И последняя функция areMergePropsEqual - которая сравнивает результаты выполнения функции mergeProps.
Ядро connect-а
Проходиться по всему коду connect займет много времени, поэтому я перейду сразу к интересному месту. А именно, где эти функции вызываются. Есть одна фабрика, которая возвращает такую не хитрую функцию как pureFinalPropsSelector (ссылка на исходники).
export function pureFinalPropsSelectorFactory(/* ... */) {
// ....
return function pureFinalPropsSelector(
nextState: State,
nextOwnProps: TOwnProps
) {
return hasRunAtLeastOnce
? handleSubsequentCalls(nextState, nextOwnProps)
: handleFirstCall(nextState, nextOwnProps)
}
}
В качестве параметров она принимает nextState - это состояние всего стора и nextOwnProps - это собственно props переданные в компонент. И возвращает эта функция результат выполнения одной из двух функций. В зависимости первый раз рендериться этот компонент или нет.
Посмотрим для начала функцию, которая вызывается на первом рендере.
function handleFirstCall(firstState: State, firstOwnProps: TOwnProps) {
state = firstState
ownProps = firstOwnProps
// @ts-ignore
stateProps = mapStateToProps(state, ownProps)
// @ts-ignore
dispatchProps = mapDispatchToProps!(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
hasRunAtLeastOnce = true
return mergedProps
}
Функция, вычисляет значения mapStateToProps, mapDispatchToProps, mergeProps переключает флаг hasRunAtLeastOnce на true и на этом первый рендер окончен. Возвращает эта функция mergedProps, который мы и получаем в нашем компоненте.
С другой стороны функция, которая вызывается на второй и последующие рендеры, она более интересная.
function handleSubsequentCalls(nextState: State, nextOwnProps: TOwnProps) {
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
const stateChanged = !areStatesEqual(nextState, state)
state = nextState
ownProps = nextOwnProps
if (propsChanged && stateChanged) return handleNewPropsAndNewState()
if (propsChanged) return handleNewProps()
if (stateChanged) return handleNewState()
return mergedProps
}
Сначала она сравнивает не изменились ли props.
const propsChanged = !areOwnPropsEqual(nextOwnProps, ownProps)
Потом проверяет не изменился ли сам redux store.
const stateChanged = !areStatesEqual(nextState, state)
И в зависимости от того что именно изменилось, уже решает, какую именно функцию вызывать. Самый популярный случай, это все же изменения именно store. Поэтому мы рассмотрим функцию handleNewState.
function handleNewState() {
const nextStateProps = mapStateToProps(state, ownProps)
const statePropsChanged = !areStatePropsEqual(nextStateProps, stateProps)
// @ts-ignore
stateProps = nextStateProps
if (statePropsChanged)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
Код достаточно простой. Вычисляется новый результат выполнения функции mapStateToProps и далее с помощью той самой функции areStatePropsEqual сравнивается с предыдущим значением. И если statePropsChanged равно true, тогда мержатся все props в один объект и отдаются обернутому компоненту.
Чтобы вы не пугались функции mergeProps. Если мы не передаем ее третьим параметром, в этом случае по дефолту она выглядит вот так:
export function defaultMergeProps<TStateProps, TDispatchProps, TOwnProps>(
stateProps: TStateProps,
dispatchProps: TDispatchProps,
ownProps: TOwnProps
) {
return { ...ownProps, ...stateProps, ...dispatchProps }
}
Согласитесь, крайне просто.
Подытожить исследования исходников connect можно мыслью, что вся магия держится на одном простом сравнении результата выполнения mapStateToProps с помощью shallowEqual. Но, надо обязательно помнить, что shallowEqual имеет свои ограничения.
Как не стоит делать
Давайте рассмотрим несколько неудачных примеров:
const mapStateToProps = (state) => {
const { user } = state
return {
user: {
...user,
name: prepareNameToDisplay(user),
}
}
}
На первый взгляд этот код выглядит вполне себе жизнеспособным. Пользователю на экране мы хотим показать имя состоящее из нескольких свойств.
name: prepareNameToDisplay(user)
Так делать не рекомендуется. Вспомним о том как работает shallowEqual. В нашем случае, на каждый вызов mapStateToProps, мы возвращаем новую ссылку на юзера, т.к. мы создаем новый объект каждый раз. Проблема именно в этих фигурных скобках.
return {
user: { // <-- вот в этих скобках
...user,
name: prepareNameToDisplay(user),
} // <-- вот в этих скобках
}
В результате компонент, который мы обернули в connect будет рендериться в 100% случаев, когда обновляется store и даже, если обновился не user, а какие-нибудь другие данные.
const mapStateToProps = (state) => {
const { apartments } = state
return {
apartments: apartments.filter(apartment => apartment.price >= 100)
}
}
Вся загвоздка в методе filter, который всегда возвращает новый массив, а не мутирует предыдущий. Таким образом shallowEqual всегда будет возвращать false.
И соответственно, если ранее рендерился только компонент, у которого обновились используемые им данные, то при таком написании кода, абсолютно все компоненты начнут рендериться, даже если обновились данные, которые они никогда и не используют. Как вы понимаете, это плохо скажется на перфомансе вашего проекта.
Решить эту проблему можно разными путями. Но один из новых путей, с которыми мы сегодня познакомились, вероятно не самый эффективный, это передать в текущий connect 4-ым параметром функцию areStatePropsEqual, где мы сами опишем как лучше сравнивать значения между рендерами.
export default connect(mapStateToProps, mapDispatchToProps, null, {
areStatePropsEqual: someCustomFunction,
})(Users);
Если же вы не поняли, в чем именно кроется проблема, я бы рекомендовал более детально изучить тему “передача параметров по ссылке и по значению в javascript” и после погуглить "как работает метод shallowEqual". И когда вы усвоите материал вернуться к этой статье и возможно для вас откроется много нового.
Дисклеймер
В этой статье я хотел поделиться с вами тем, как работает connect под капотом. Все слова про собеседования это действительно правда, но не основная тема этой статьи.