Всем привет!
Redux вроде как изучили вдоль и поперек, но от этого он не стал идеальным. В статье мы детально разберем один из самых неприятных механизмов в Redux, которым многие стреляют себе в ногу. Собственно, этот механизм мы и изучим в статье (данная статья является расшифровкой этого видео):
Скрытная особенность useSelector
И так давайте начнем с общей схемы.
Допустим, у нас есть компонент Cars
со списком машин. Чтобы получить этот список машин, мы используем useSelector
с селектором getCars
, который, в свою очередь, идет в Redux за необходимыми нам данными. И в итоге данные начинают вытягиваться, и пользователь наконец видит список машин у себя на странице:
Далее, допустим, что в этом компоненте мы вызвали action с добавлением новой машины в список машин. Собственно этот action и обновляет Redux store, чтобы добавить новую машину в список:
Далее, как мы знаем, Redux store должен обновить наш компонент. Но вопрос, как именно он это делает с технической стороны?
Работает это следующим образом: useSelector
не просто считывает данные из Redux store при любом обновлении компонента, он также еще неявно подписывается на любые изменения Redux store (бежевые черточки на рисунке).
И когда интересующее нас значение в Redux store изменяется, useSelector
еще и заставляет весь текущий компонент пере рисоваться. В итоге после dispatch(addCar(newCar))
, соответствующий reducer обновит наш Redux store. И благодаря тому, что useSelector
подписан на изменения в Redux store мы получаем желанный рендер компонента с новым значением cars
, где уже в списке присутствует только что добавленная машина:
Получается, что useSelector
не так прост, как кажется. Это не просто метод для получения данных из Redux store. На нем лежит куда больше ответственности, чем кажется на первый взгляд.
Масштабируем ситуацию
Чтобы понять всю ответственность, которая лежит на useSelector
, давайте немного масштабируем пример. Допустим, у нас есть страничка, на которой отображен текущий пользователь. Ниже мы видим компонент со списком тех самых машин, а после него пусть еще будет список дилеров, у которых мы можем приобрести новое авто:
Каждый из этих компонентов нуждается в данных из Redux. Это значит, что у них всех используется useSelector
. Только каждый компонент тянет свои данные. Компонент с текущим пользователем использует селектор getCurrentUser
, компонент с машинами, соответственно, тянет список машин с помощью getCars
. И компонент с дилерами, конечно же, тянет список дилеров с помощью getDealers
. На что хотелось бы обратить внимание: все эти селекторы тянут информацию из одного и того же Redux store:
Вроде с начальными условиями разобрались.
Давайте теперь представим, что мы вызвали тот самый action с добавлением новой машины. Вследствие чего Redux store должен обновиться и, соответственно, отправить всем useSelector
сигнал, мол, проверьте, нужно обновить ваши компоненты или нет. И, конечно, мы ожидаем, что useSelector
компонента с машинами единственный обновится в данной ситуации. Это звучит логично, потому что мы добавили машину, а не обновили текущего пользователя или добавили дилера
Звучит крайне просто и удобно, но у этого всего есть одно узкое горлышко – useSelector
, а именно механизм внутри него, который принимает решения, должен обновиться компонент или нет. Этот механизм настолько хрупкий, что я неоднократно видел, как на разных проектах вместо одного компонента Cars
обновляются на любой чих сразу все компоненты. И неважно, добавляли мы машину или редактировали текущего пользователя.
Просто задумайтесь, насколько велика цена непонимания работы этого механизма, если у вас вся страница зависит от Redux данных. Неправильная работа с этим механизмом может заставить перерисовывать абсолютно все приложение, на абсолютно любой action. А actions у вас может быть огромное количество. В таком приложении о хорошем быстродействии приложения можно только молиться на выносливость устройств пользователей.
Изучаем узкое горлышко
Самое время рассмотреть в деталях, что из себя представляет тот самый механизм. Покопавшись в исходниках, я бы акцентировал внимание на следующем месте. Начнем с 70 и 71 строк:
Здесь мы создаем переменную prevSnapshot
и ниже prevSelection
. Что означают слова prev
, snapshot
и selection
в переводе на знакомые нам слова. Snapshot
называют то, что мы называем state
. То есть это состояние всего Redux store, из которого мы потом достаем текущего пользователя или список машин и так далее.
А selection
обозначает как раз таки интересующие нас данные, те самые машины или текущий пользователь. Те данные, которые запрашивает наш селектор.
Далее о приставке prev
и еще увидим приставку next
. Они означают следующее. Допустим, у нас есть список из 2 машин. И мы решили туда добавить еще одну машину. Соответственно, prev
состояние, это когда машины еще 2, а next
состояние – это когда машин уже 3. То есть это до изменения любой части Redux store и после изменения.
Чтобы вам было легче воспринимать исходники, я решил их переписать. Теперь код будет выглядеть со знакомыми для нас всех словами на примере со списком машин.
Первые строки мы уже разобрали. Далее мы видим if
, где сравниваются предыдущее состояние всего стора и текущее состояние всего стора. Сравниваются они с помощью функции is
. Под капотом этой функции используется оригинальная функция Object.is. Можете погуглить, как она работает, но если вкратце, в данном случае она сравнивает ссылки обоих Redux store. Если это одна и та же ссылка в памяти, мы просто возвращаем список машин из предыдущих рендеров.
Поясню, для чего это нужно на простом примере. Допустим, у нас есть компонент с каким-то локальным состоянием. А также мы достаем из Redux store тот самый список машин. Пользователь на экране видит, как система показывает, сколько у нее машин в автопарке. И спрашивает: «А сколько машин у пользователя в наличии?». И предоставлено поле ввода для ответа на этот вопрос.
Теперь представьте, что пользователь вводит значение в input
и на каждую букву вызывается функция setAnswer
. Что является причиной рендера компонента. И абсолютно на каждый рендер useSelector
возвращает один и тот же список машин, потому что состояние Redux store не меняется в этих случаях. И чтобы useSelector
не делал лишних вычислений и существует та самая if
проверка в коде, где сравнивается, изменялся ли Redux store с предыдущего рендера.
Хорошо, теперь допустим, что Redux store изменился и рассмотрим дальнейший код.
Так как Redux store изменился, но мы не знаем, что именно изменилось, будет логично достать список машин из next Redux store и также его проверить на изменения. Что, собственно, и происходит. И далее с помощью функции isEqual
сравниваем, изменился список машин или не изменился.
И если список машин не изменился, то и возвращаем список машин из предыдущих рендеров. А если изменился, в таком случае возвращаем уже обновленный список машин и не просто возвращаем обновленный список машин, а еще и заставляем компонент рендериться. То, о чем мы говорили в предыдущей главе.
Получается вот эти два if-условия и есть тот самый механизм, который решает кому рендериться после любого изменения в Redux store, а кому не нужно рендериться. Более подробно, как именно useSelector
заставляет рендериться компонент, вы можете узнать в другом моем видео с названием «Все ли вы знаете о useSelector?», а пока давайте вернемся к нашим if-условиям.
Если в первом if-условии мы видим, что сравнение идет с помощью Object.is и вообще сравниваются инстансы Redux store, которые не мы создаем, в таком случае шансы сломать что-то здесь не велики.
С другой стороны, второе if-условие выглядит куда опаснее. Здесь сравниваются непосредственно те данные, которые мы возвращаем из селектора, т. е. в данном случае именно мы подготовили список машин. И будет ли список машин одним и тем же, зависит, конечно же, от способа сравнения. В данном случае мы видим, что проверка осуществляется с помощью функции isEqual
. И чтобы понять, что именно это за функция, нужно перейти к исходникам хука useSelector
Как вы видите, useSelector
принимает не один, а целых два параметра. Второй параметр называется equalityFn
, который вы можете передать. То есть, вот таким способом вы можете передать функцию deepEqual
, которая будет глубоко сравнивать список машин.
Но чаще всего мы не передаем никакую функцию вторым параметром. Так что же за функция в таком случае сравнивает списки машин? Если вернуться к коду и внимательно посмотреть, вы увидите, что по дефолту подставляется функция refEquality
. Она описана немного выше в том же файле. Все что она делает, это сравнивает два значения с помощью тройного равно. Да, все так просто. В этом и кроется хрупкость данного механизма.
Чтобы понимать всю проблемность этой проверки, нужно хорошо понимать разницу между сравнением по ссылке и по значению. Если вы не уверены, что понимаете, о чем идет речь, попробуйте погуглить фразу «js сравнение по ссылке и по значению».
Пример проблемы
Чтобы понять всю проблему происходящего, давайте рассмотрим пример. У нас есть селектор все тех же машин. У пользователя есть фильтры по минимальной и максимальной цене машины. И нам нужно вернуть только список тех машин, который удовлетворяет фильтрам.
Решение вроде как тривиальное, но есть маленький подвох. Метод filter
всегда, от слова 100%, возвращает новую ссылку массива.
Это значит, что если один и тот же селектор, с одними и теми же параметрами вызвать дважды и сравнить, то они не будут эквивалентными. Хоть внутри и будут полностью одинаковые списки машин.
Соответственно, если вернуться к примерам ранее.
Не важно обновился у вас список дилеров или же текущий пользователь. У вас всегда будет рендериться компонент с машинами, так как getFilteredCars
селектор всегда будет возвращать новую ссылку на список машин.
А представьте, если у вас в приложении много такого рода сломанных селекторов. И теперь на абсолютно любой экшен будет рендериться чуть ли не вся страница, просто потому что селектор написан не очень удачно. О хорошем быстродействии, мне кажется, в таком приложении мечтать уже не приходится.
Конечно же, решение такого рода проблем уже придумано и даже есть имя у этого решения – reselect
. Но сегодня его разбирать не будем
Для чего писалась эта статья
Идея данной статьи – познакомить вас с тонкостями интеграции Redux в React. Возможно, эта проблема присутствует в вашем проекте и никак не сказывается на работоспособности приложения, но теперь вы будете хотя бы знать о ней и возможно не будете доставлять такие селекторы в Prod в будущем.
Комментарии