🚗 Самый «скользкий» механизм в Redux – useSelector

Redux вроде как изучили вдоль и поперек, но от этого он не стал идеальным. В данной статье мы детально разберем один из самых неприятных механизмов в Redux, которым многие стреляют себе в ногу – useSelector.
🚗 Самый «скользкий» механизм в Redux – useSelector

Всем привет!

Redux вроде как изучили вдоль и поперек, но от этого он не стал идеальным. В статье мы детально разберем один из самых неприятных механизмов в Redux, которым многие стреляют себе в ногу. Собственно, этот механизм мы и изучим в статье (данная статья является расшифровкой этого видео):

Скрытная особенность useSelector

И так давайте начнем с общей схемы.

Допустим, у нас есть компонент Cars со списком машин. Чтобы получить этот список машин, мы используем useSelector с селектором getCars, который, в свою очередь, идет в Redux за необходимыми нам данными. И в итоге данные начинают вытягиваться, и пользователь наконец видит список машин у себя на странице:

🚗 Самый «скользкий» механизм в Redux – useSelector

Далее, допустим, что в этом компоненте мы вызвали action с добавлением новой машины в список машин. Собственно этот action и обновляет Redux store, чтобы добавить новую машину в список:

🚗 Самый «скользкий» механизм в Redux – useSelector

Далее, как мы знаем, Redux store должен обновить наш компонент. Но вопрос, как именно он это делает с технической стороны?

Работает это следующим образом: useSelector не просто считывает данные из Redux store при любом обновлении компонента, он также еще неявно подписывается на любые изменения Redux store (бежевые черточки на рисунке).

И когда интересующее нас значение в Redux store изменяется, useSelector еще и заставляет весь текущий компонент пере рисоваться. В итоге после dispatch(addCar(newCar)), соответствующий reducer обновит наш Redux store. И благодаря тому, что useSelector подписан на изменения в Redux store мы получаем желанный рендер компонента с новым значением cars, где уже в списке присутствует только что добавленная машина:

🚗 Самый «скользкий» механизм в Redux – useSelector

Получается, что useSelector не так прост, как кажется. Это не просто метод для получения данных из Redux store. На нем лежит куда больше ответственности, чем кажется на первый взгляд.

Масштабируем ситуацию

Чтобы понять всю ответственность, которая лежит на useSelector, давайте немного масштабируем пример. Допустим, у нас есть страничка, на которой отображен текущий пользователь. Ниже мы видим компонент со списком тех самых машин, а после него пусть еще будет список дилеров, у которых мы можем приобрести новое авто:

🚗 Самый «скользкий» механизм в Redux – useSelector

Каждый из этих компонентов нуждается в данных из Redux. Это значит, что у них всех используется useSelector. Только каждый компонент тянет свои данные. Компонент с текущим пользователем использует селектор getCurrentUser, компонент с машинами, соответственно, тянет список машин с помощью getCars. И компонент с дилерами, конечно же, тянет список дилеров с помощью getDealers. На что хотелось бы обратить внимание: все эти селекторы тянут информацию из одного и того же Redux store:

🚗 Самый «скользкий» механизм в Redux – useSelector

Вроде с начальными условиями разобрались.

Давайте теперь представим, что мы вызвали тот самый action с добавлением новой машины. Вследствие чего Redux store должен обновиться и, соответственно, отправить всем useSelector сигнал, мол, проверьте, нужно обновить ваши компоненты или нет. И, конечно, мы ожидаем, что useSelector компонента с машинами единственный обновится в данной ситуации. Это звучит логично, потому что мы добавили машину, а не обновили текущего пользователя или добавили дилера

🚗 Самый «скользкий» механизм в Redux – useSelector

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

🚗 Самый «скользкий» механизм в Redux – useSelector

Просто задумайтесь, насколько велика цена непонимания работы этого механизма, если у вас вся страница зависит от Redux данных. Неправильная работа с этим механизмом может заставить перерисовывать абсолютно все приложение, на абсолютно любой action. А actions у вас может быть огромное количество. В таком приложении о хорошем быстродействии приложения можно только молиться на выносливость устройств пользователей.

Изучаем узкое горлышко

Самое время рассмотреть в деталях, что из себя представляет тот самый механизм. Покопавшись в исходниках, я бы акцентировал внимание на следующем месте. Начнем с 70 и 71 строк:

        // We may be able to reuse the previous invocation's result.
const prevSnapshot: Snapshot = (memoizedSnapshot: any);
const prevSelection: Selection = (memoizedSelection: any);
    

Здесь мы создаем переменную prevSnapshot и ниже prevSelection. Что означают слова prev, snapshot и selection в переводе на знакомые нам слова. Snapshot называют то, что мы называем state. То есть это состояние всего Redux store, из которого мы потом достаем текущего пользователя или список машин и так далее.

        const getCars = (state) => state.cars
const getCurrentUser = (state) => state.currentUser
    

А selection обозначает как раз таки интересующие нас данные, те самые машины или текущий пользователь. Те данные, которые запрашивает наш селектор.

Далее о приставке prev и еще увидим приставку next. Они означают следующее. Допустим, у нас есть список из 2 машин. И мы решили туда добавить еще одну машину. Соответственно, prev состояние, это когда машины еще 2, а next состояние – это когда машин уже 3. То есть это до изменения любой части Redux store и после изменения.

Чтобы вам было легче воспринимать исходники, я решил их переписать. Теперь код будет выглядеть со знакомыми для нас всех словами на примере со списком машин.

        // We may be able to reuse the previous invocation's result.
const prevState: State = (memoizedSnapshot: any);
const prevCars: Cars = (memoizedSelection: any);

if (is(prevState, nextState)) {
  // The snapshot is the same as last time. Reuse the previous selection.
  return prevState;
}

// The snapshot has changed, so we need to compute a new selection.
const nextCars = getCars(nextState);

// If a custom isEqual function is provided, use that to check if the data
// has changed. If it hasn't, return the previous selection. That signals
// to React that the selections are conceptually equal, and we can bail
// out of rendering.
if (isEqual !== undefined && isEqual(prevCars, nextCars)) {
  return prevCars;
}

memoizedSnapshot = nextState;
memoizedSelection = nextCars;
return nextCars;
    

Первые строки мы уже разобрали. Далее мы видим if, где сравниваются предыдущее состояние всего стора и текущее состояние всего стора. Сравниваются они с помощью функции is. Под капотом этой функции используется оригинальная функция Object.is. Можете погуглить, как она работает, но если вкратце, в данном случае она сравнивает ссылки обоих Redux store. Если это одна и та же ссылка в памяти, мы просто возвращаем список машин из предыдущих рендеров.

Поясню, для чего это нужно на простом примере. Допустим, у нас есть компонент с каким-то локальным состоянием. А также мы достаем из Redux store тот самый список машин. Пользователь на экране видит, как система показывает, сколько у нее машин в автопарке. И спрашивает: «А сколько машин у пользователя в наличии?». И предоставлено поле ввода для ответа на этот вопрос.

        import { useState } from 'react';
import { useSelector } from 'react-redux';

export const SomeComponent = () => {
  const [answer, setAnswer] = useState('');
  
  const cars = useSelector(state => state.cars);

  const onChange = (event) => {
    setAnswer(event.target.value);
  }

  return (
    <div>
      <h1>У меня {cars.length} машин! а у тебя?</h1>
      <input value={answer} onChange={onChange} />
    </div>
  );
};
    

Теперь представьте, что пользователь вводит значение в input и на каждую букву вызывается функция setAnswer. Что является причиной рендера компонента. И абсолютно на каждый рендер useSelector возвращает один и тот же список машин, потому что состояние Redux store не меняется в этих случаях. И чтобы useSelector не делал лишних вычислений и существует та самая if проверка в коде, где сравнивается, изменялся ли Redux store с предыдущего рендера.

Хорошо, теперь допустим, что Redux store изменился и рассмотрим дальнейший код.

        // The snapshot has changed, so we need to compute a new selection.
const nextCars = getCars(nextState);

// If a custom isEqual function is provided, use that to check if the data
// has changed. If it hasn't, return the previous selection. That signals
// to React that the selections are conceptually equal, and we can bail
// out of rendering.
if (isEqual !== undefined && isEqual(prevCars, nextCars)) {
  return prevCars;
}

memoizedSnapshot = nextState;
memoizedSelection = nextCars;
return nextCars;
    

Так как Redux store изменился, но мы не знаем, что именно изменилось, будет логично достать список машин из next Redux store и также его проверить на изменения. Что, собственно, и происходит. И далее с помощью функции isEqual сравниваем, изменился список машин или не изменился.

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

Получается вот эти два if-условия и есть тот самый механизм, который решает кому рендериться после любого изменения в Redux store, а кому не нужно рендериться. Более подробно, как именно useSelector заставляет рендериться компонент, вы можете узнать в другом моем видео с названием «Все ли вы знаете о useSelector?», а пока давайте вернемся к нашим if-условиям.

Если в первом if-условии мы видим, что сравнение идет с помощью Object.is и вообще сравниваются инстансы Redux store, которые не мы создаем, в таком случае шансы сломать что-то здесь не велики.

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

        return function useSelector<TState, Selected extends unknown>(
  selector: (state: TState) => Selected,
  equalityFn: EqualityFn<NoInfer<Selected>> = refEquality
) {

// ...

}
    

Как вы видите, useSelector принимает не один, а целых два параметра. Второй параметр называется equalityFn, который вы можете передать. То есть, вот таким способом вы можете передать функцию deepEqual, которая будет глубоко сравнивать список машин.

        const cars = useSelector(getCars, deepEqual);
    

Но чаще всего мы не передаем никакую функцию вторым параметром. Так что же за функция в таком случае сравнивает списки машин? Если вернуться к коду и внимательно посмотреть, вы увидите, что по дефолту подставляется функция refEquality. Она описана немного выше в том же файле. Все что она делает, это сравнивает два значения с помощью тройного равно. Да, все так просто. В этом и кроется хрупкость данного механизма.

        const refEquality: EqualityFn<any> = (a, b) => a === b
    

Чтобы понимать всю проблемность этой проверки, нужно хорошо понимать разницу между сравнением по ссылке и по значению. Если вы не уверены, что понимаете, о чем идет речь, попробуйте погуглить фразу «js сравнение по ссылке и по значению».

Пример проблемы

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

        const getFilteredCars = (state) => {
  const minPrice = getMinPriceFilter(state);
  const maxPrice = getMaxPriceFilter(state);
  const cars = getCars(state);

  return cars.filter(
    (car) => car.price >= minPrice && car.price <= maxPrice
  );
};
    

Решение вроде как тривиальное, но есть маленький подвох. Метод filter всегда, от слова 100%, возвращает новую ссылку массива.

Это значит, что если один и тот же селектор, с одними и теми же параметрами вызвать дважды и сравнить, то они не будут эквивалентными. Хоть внутри и будут полностью одинаковые списки машин.

        getFilteredCars(state) !== getFilteredCars(state)
    

Соответственно, если вернуться к примерам ранее.

🚗 Самый «скользкий» механизм в Redux – useSelector

Не важно обновился у вас список дилеров или же текущий пользователь. У вас всегда будет рендериться компонент с машинами, так как getFilteredCars селектор всегда будет возвращать новую ссылку на список машин.

🚗 Самый «скользкий» механизм в Redux – useSelector

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

Конечно же, решение такого рода проблем уже придумано и даже есть имя у этого решения – reselect. Но сегодня его разбирать не будем

Для чего писалась эта статья

Идея данной статьи – познакомить вас с тонкостями интеграции Redux в React. Возможно, эта проблема присутствует в вашем проекте и никак не сказывается на работоспособности приложения, но теперь вы будете хотя бы знать о ней и возможно не будете доставлять такие селекторы в Prod в будущем.

***
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека фронтендера»

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ