Мы слишком много используем redux-селекторы

в 6:49, , рубрики: DDD, javascript, React, ReactJS, redux, TypeScript

Когда я заглядываю в файл {domain}/selectors.js в больших проектах на React/Redux, с которыми работаю, я часто встречаю огромный список redux-селекторов подобного вида:

getUsers(state)
getUser(id)(state)
getUserId(id)(state)
getUserFirstName(id)(state)
getUserLastName(id)(state)
getUserEmailSelector(id)(state)
getUserFullName(id)(state)
…

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

image

Redux и селекторы

Давайте рассмотрим Redux. Что он такое, зачем? Почитав сайт redux.js.org мы понимаем, что Redux — это "предсказуемый контейнер для хранения стейта приложений на JavaScript"

При использовании Redux нам рекомендуется использовать селекторы, даже если они не обязательны. Селекторы — это просто геттеры для получения некоторых частей из целого стейта, т.е. функции вида (State) => SubState. Обычно мы пишем селекторы чтобы не обращаться к стейту напрямую, а так же чтобы потом можно было комбинировать или мемоизировать результаты работы этих селекторов. Звучит разумно.

Сильно погружаясь в селекторы

Список селекторов, что я привел в введении к этой статье, носит характерные признаки кода, созданного в спешке.
Представьте, что у нас есть модель User, и мы захотим добавить к ней новое поле email. У нас есть компонент, который ожидал на вход firstName и lastName, а сейчас он станет ожидать еще email. Следуя логике, заложенной в коде с селекторами, вводя новое поле email автор должен добавить селектор getUserEmailSelector и использовать его для передачи этого поля в компонент. Бинго!

Но "бинго" ли? А если у нас появится еще один селектор, который будет более сложным? Мы скомбинируем его с другими селекторами, и возможно придем к такой картине:

const getUsers = (state) => state.users;
const getUser = (id) => (state) => getUsers(state)[id];
const getUserEmailSelector = (id) => (state) => getUser(id)(state).email;

Возникает первый вопрос: что должен вернуть селектор getUserEmailSelector в случае, если селектор getUser вернет undefined? А это вероятная ситуация — баги, рефакторинг, легаси — все может привести к ней. Вообще говоря, это ни разу не задача селекторов — обрабатывать ошибки или предоставлять значения "по-умолчанию".

Вторая проблема возникает с тестированием таких селекторов. Если мы захотим покрыть их юнит-тестами — нам понадобятся mock-данные, идентичные данным с продакшна. Мы должны будем использовать mock-данные всего стейта (т.к. на продакшне состояние не может быть неконсистентным) всего лишь для одного этого селектора. Это, в зависимости от архитектуры нашего приложения может быть весьма неудобно — затаскивать данные в тесты.

Давайте предположим, что мы написали и протестировали селектор getUserEmailSelector как описано выше. Используем его и подключим компонент к стейту:

const mapStateToProps = (state, ownProps) => ({
  firstName: getUserFirstName(ownProps.userId)(state),
  lastName: getUserLastName(ownProps.userId)(state),

  // новые данные
  email: getUserEmailName(ownProps.userId)(state),
})

Следуя перечисленной выше логике мы и получили ту кучу селекторов, что была в начале статьи.
Мы зашли слишком далеко. В итоге мы написали псевдо-API для сущности User. Это API невозможно использовать вне контекста Redux, потому что оно требует полного слепка стейта. Кроме того, это API сложно расширять — при добавлении новых полей к сущности User мы должны создавать новые селекторы, добавлять их в mapStateToProps, писать все больше boilerplate-кода.

А может стоит обращаться к полям сущности напрямую?

Если проблема заключается только в том, что у нас слишком много селекторов — может быть нам просто использовать getUser и обращаться к нужным нам свойствам сущности напрямую?

const user = getUser(id)(state);
const email = user.email;

Такой подход решает проблему написания и поддержки огромного количества селекторов, однако создает другую проблему. Если нам понадобится изменить модель User, мы так же должны будем проконтролировать все места, где встречается user.email (примечание переводчика или другое изменяемое нами поле). При большом объема кода в проекте это может стать сложной задачей и затруднить даже небольшой рефакторинг. Когда у нас был селектор — он защищал нас от таких последствий изменений, т.к. брал на себя обязанность работы с моделью и код, использующий селектор, ничего о модели не знал.

С прямым доступом понятно. А как быть в случае получения рассчитываемых данных? Например с получением полного имени пользователя, которое является конкатенацией имени и фамилии? Нужно копать дальше...

image

Модель предметной области — во главе. Redux — вторичен

Прийти к такой картине можно ответив на два вопроса:

  • Как мы определим нашу модель предметной области?
  • Как мы будем хранить данные? (стейт-менеджмент, для него используем redux *примечание переводчика* то, что в DDD зовется persistence layer)

Отвечая на вопрос "Как мы определим модель предметной области" (в нашем случае User) давайте абстрагируемся от redux и решим, что такое "пользователь" и какое API необходимо для взаимодействия с ним?

// api.ts
type User = {
  id: string,
  firstName: string,
  lastName: string,
  email: string,
  ...
}

const getFirstName = (user: User) => user.firstName;
const getLastName = (user: User) => user.lastName;
const getFullName = (user: User) 
  => `${user.firstName} ${user.lastName}`;
const getEmail = (user: User) => user.email;
...
const createUser = (id: string, firstName: string, ...) => User;

Будет хорошо, если мы всегда будем использовать это API и считать модель User недоступной за пределами файла api.ts. Это значит, что мы никогда не обратимся напрямую к полям сущности т.к. код, который использует API даже не знает, какие у сущности есть поля.

Теперь мы можем вернуться к Redux и решить вопросы, касающиеся только стейта:

  • Какое место занимают пользователи в нашем стейте?
  • Как мы должны хранить пользователей? Списком? Словарем (key-value)? Как-нибудь иначе?
  • Как мы будем получать экземпляр User из стейта? Использовать ли мемоизацию? (в контексте селектора getUser)

Маленькое API с большими преимуществами

Применяя принцип разделения ответственности между предметной областью и стейтом мы получаем множество бонусов.

Хорошо документированная модель предметной области (модель User и её API) в файле api.ts. Она хорошо поддается тестированию, т.к. не имеет никаких зависимостей. Мы можем извлечь модель и API в библиотеку для переиспользования их в других приложениях.

Мы можем легко комбинировать функции API как селекторы, что является несравненным преимуществом по сравнению с прямым доступом к свойствам. Кроме того, наш интерфейс с данными теперь легко поддерживать в будущем — мы можем легко изменить модель User без изменения использующего её кода.

Не произошло никакой магии и с API, оно все так же выглядит понятным. API имеет сходство с тем, что было сделано с помощью селекторов, но имеет одно ключевое отличие: ему не требуется весь стейт целиком, не нужно больше поддерживать полный стейт приложения для тестирования — API никак не связано с Redux и его boilerplate-кодом.

Пропсы компонента стали более чистыми. Вместо ожидания на вход свойств firstName, lastName и email компонент получает экземпляр User и внутри себя использует её API для доступа к нужным данным. Получается, нам нужен всего один селектор — getUser.

Есть польза и для редьюсеров и мидлварей от такого API. Суть пользы в том, что можно сперва получить экземпляр User, разобраться с недостающими в ней значениями, обработать или предотвратить все ошибки, а уже после этого использовать методы API. Это лучше, чем использовать получение каждого отдельного поля с помощью селекторов в отрыве от предметной области. Таким образом Redux действительно становится "предсказуемым контейнером" и перестает быть "божественным" объектом со знанием обо всем.

Заключение

Благими намерениями (здесь читай — селекторами) вымощена дорога в ад: мы не хотели обращаться к полям сущности напрямую и сделали для этого отдельные селекторы.

Хотя сама идея селекторов хороша, её чрезмерное использование ведет к сложности в поддержке нашего кода.

Описанное в статье решение предлагает решать задачу в два этапа — сперва опишите доменную модель и её API, затем разберитесь с тем, что касается Redux (хранение данных, селекторы). Таким образом вы напишете более качественный и меньший по объему код — нужен будет всего один селектор для создания более гибкого и масштабируемого API.

Примечания переводчика

  1. Я использовал слово стейт, тк кажется оно достаточно прочно вошло в лексикон русскоговорящих разработчиков.
  2. Автор использует слова upstream/downstream для обозначения "высокоуровневый/низкоуровневый код"(если по Мартину) или "код, которым пользуются ниже/код уровнем ниже, который использует то, что написано выше", но корректно придумать как использовать это в переводе я не смог, поэтому утешаю себя тем, что постарался не нарушить общего смысла.

Замечания и предложения по исправлениям с удовольствием приму в личку и поправлю.

Автор: Владимир

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js