Автор материала, перевод которого мы сегодня публикуем, говорит, что входит в команду мессенджера Hike, которая занимается новыми возможностями приложения. Цель этой команды заключается в том, чтобы воплощать в реальность и исследовать идеи, которые могут понравиться пользователям. Это означает, что действовать разработчикам нужно оперативно, и что им приходится часто вносить изменения в исследуемые ими новшества, которые направлены на то, чтобы сделать работу пользователей как можно более удобной и приятной. Они предпочитают проводить свои эксперименты с применением React Native, так как эта библиотека ускоряет разработку и позволяет использовать один и тот же код на разных платформах. Кроме того, они пользуются библиотекой Redux.



Когда разработчики из Hike начинают работу над чем-то новым, то, при обсуждении архитектуры исследуемого решения, у них возникает несколько вопросов:

  • Это — экспериментальная возможность, которая может, что называется, «не взлететь», и от неё придётся отказаться. Нужно ли, учитывая это, тратить время на проектирование архитектуры приложения?
  • Экспериментальное приложение — это всего лишь MVP, минимально жизнеспособный продукт, в котором имеется 1-2 экрана и который надо создать как можно быстрее. Стоит ли, учитывая это, связываться с Redux?
  • Как оправдать перед менеджерами по продукту время, необходимое на подготовку вспомогательной инфраструктуры экспериментального приложения?

Собственно говоря, найти правильные ответы на все эти вопросы помогает Redux. Архитектура Redux способствует отделению состояния приложения от React. Она позволяет создать глобальное хранилище, находящееся на верхнем уровне приложения и предоставляющее доступ к состоянию для всех остальных компонентов.

Разделение ответственностей


Что такое «разделение ответственностей»? Вот что говорит об этом Википедия: «В информатике разделение ответственностей представляет собой процесс разделения компьютерной программы на функциональные блоки, как можно меньше перекрывающие функции друг друга. В более общем случае, разделение ответственностей — это упрощение единого процесса решения задачи путём разделения на взаимодействующие процессы по решению подзадач».

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


Архитектура Redux

Вот краткая характеристика этих блоков:

  • Представления или компоненты пользовательского интерфейса (UI Components) напоминают чистые функции (то есть такие функции, которые не изменяют переданные им данные и обладают некоторыми другими свойствами), которые ответственны за вывод информации на экран на основе данных, переданных им из хранилища. Они не меняют данные напрямую. При возникновении какого-либо события, или если с ними взаимодействует пользователь, они обращаются к создателям действий.
  • Создатели действий (Action Creators) ответственны за создание и диспетчеризацию действий.
  • Редьюсеры (Reducers) получают диспетчеризованные действия и обновляют состояние хранилища.
  • Хранилище (Data Store) ответственно за хранение данных приложения.

Рассмотрим архитектуру Redux на примере.

Что делать, если разным компонентам нужны одни и те же данные?


В приложении Hike есть экран, отображающий список друзей пользователя. В верхней части этого экрана выводится информация об их количестве.


Экран с информацией о друзьях в приложении Hike

Здесь имеется 3 React-компонента:

  • FriendRow — компонент, содержащий имя друга пользователя и некоторые другие сведения о нём.
  • FriendsHeader — компонент, который выводит надпись «MY FRIENDS» и сведения о количестве друзей.
  • ContainerView — компонент-контейнер, который объединяет заголовок экрана, представленный компонентом FriendsHeader, и список друзей, полученный путём обхода массива, содержащего сведения о друзьях пользователя, каждый элемент которого оказывается представленным на экране компонентом FriendRow.

Вот код файла friendsContainer.js, иллюстрирующий вышесказанное:

class Container extends React.Component {

    constructor(props) {
      super(props);
      this.state = {
        friends: []
      };
    }

    componentDidMount() {
      FriendsService.fetchFriends().then((data) => {
        this.setState({
          friends: data
        });
      });
    }

    render() {
      const { friends } = this.state;
      return (
        <View style={styles.flex}>
        <FriendsHeader count={friends.length} text='My friends' />
        {friends.map((friend) => (<FriendRow {...friend} />)) }
        </View>
      );
    }
}

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

Подумаем теперь о том, что эти данные о друзьях могут понадобиться и в каких-то других компонентах, используемых в приложении.


Экран чатов в приложении Hike

Предположим, в приложении имеется экран чатов, который также содержит список друзей. Видно, что и на экране со списком друзей, и на экране чатов используются одни и те же данные. Как поступить в подобной ситуации? У нас есть два варианта:

  • Можно загрузить данные о друзьях снова, в компоненте ComposeChat, ответственном за вывод списков чатов. Однако такой подход не особенно хорош, так как его применение будет означать дублирование данных и может привести к проблемам с синхронизацией.
  • Можно загрузить данные о друзьях в компоненте высшего уровня (главный контейнер приложения) и передать эти данные компонентам, ответственным за вывод списка друзей и вывод списка чатов. Кроме того, нам надо передать этим компонентам функции для обновления данных о друзьях, что необходимо для поддержки синхронизации данных между компонентами. Такой подход приведёт к тому, что компонент высшего уровня будет буквально набит методами и данными, которые он сам напрямую не использует.

Оба эти варианта не так уж и привлекательны. Посмотрим теперь на то, как нашу проблему можно решить с использованием архитектуры Redux.

Использование Redux


Здесь речь идёт об организации работы с данными с использованием хранилища, создателей действий, редьюсеров и двух компонентов пользовательского интерфейса.

?1. Хранилище данных


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

?2. Создатели действий


В данном случае создатель действия используется для диспетчеризации событий, направленных на сохранение и обновление данных о друзьях. Вот код файла friendsActions.js:

export const onFriendsFetch = (friendsData) => {
  return {
    type: 'FRIENDS_FETCHED',
    payload: friendsData
  };
};

?3. Редьюсеры


Редьюсеры ожидают поступления событий, представляющих диспетчеризованные действия, и обновляют данные о друзьях. Вот код файла friendsReducer.js:

const INITIAL_STATE = {
       friends: [],
    friendsFetched: false
};

function(state = INITIAL_STATE, action) {
    switch(action.type) {
    case 'FRIENDS_FETCHED':
        return {
            ...state,
            friends: action.payload,
            friendsFetched: true
        };
    }
}

?4. Компонент, выводящий список друзей


Этот компонент-контейнер просматривает данные о друзьях и обновляет интерфейс при их изменении. Кроме того, он ответственен за загрузку данных из хранилища в том случае, если их у него нет. Вот код файла friendsContainer.js:

class Container extends React.Component {

    constructor(props) {
      super(props);
    }

    componentDidMount() {
      if(!this.props.friendsFetched) {
        FriendsService.fetchFriends().then((data) => {
          this.props.onFriendsFetch(data);
        });
      }
    }

    render() {
      const { friends } = this.props;
      return (
        <View style={styles.flex}>
        <FriendsHeader count={friends.length} text='My friends' />
        {friends.map((friend) => (<FriendRow {...friend} />)) }
        </View>
      );
    }
}

const mapStateToProps = (state) => ({
  ...state.friendsReducer
});

const mapActionToProps = (dispatch) => ({
  onFriendsFetch: (data) => {
    dispatch(FriendActions.onFriendsFetch(data)); 
  }
});

export default connect(mapStateToProps, mapActionToProps)(Container);

?5. Компонент, выводящий список чатов


Этот компонент-контейнер так же пользуется данными из хранилища и реагирует на их обновление.

О реализации архитектуры Redux


Для того чтобы довести вышеописанную архитектуру до рабочего состояния может понадобиться день-два, но когда в проект нужно будет вносить изменения, производятся они очень просто и быстро. Если в приложение понадобится добавить новый компонент, использующий данные о друзьях, сделать это можно без необходимости беспокоиться о синхронизации данных или о том, что придётся переделывать другие компоненты. То же самое касается и удаления компонентов.

Тестирование


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

Каждый компонент можно всесторонне протестировать, используя самые разные данные. Такое тестирование позволяет выявить скрытые проблемы и способствует обеспечению высокого качества кода.

Надо отметить, что независимому тестированию могут быть подвергнуты не только компоненты, ответственные за визуализацию данных, но и редьюсеры, и создатели действий.

Redux — это замечательно, но используя эту технологию мы столкнулись с некоторыми трудностями.

Трудности при использовании Redux


?Избыток шаблонного кода


Для того чтобы реализовать в приложении архитектуру Redux, приходится потратить немало времени, сталкиваясь при этом со всяческими странными понятиями и сущностями.

Это так называемые санки (thunks), редьюсеры (reducers), действия (actions), промежуточные программные слои (middlewares), это функции mapStateToProps и mapDispatchToProps, а также многое другое. На то, чтобы всё это изучить, нужно время, а для того, чтобы научиться правильно этим пользоваться, требуется практика. В проекте оказывается очень много файлов, и, например, одно незначительное изменение компонента для визуализации данных может привести к необходимости вносить правки в четыре файла.

?Хранилище Redux — это синглтон


В Redux хранилище данных построено с использованием паттерна «синглтон», хотя компоненты могут иметь несколько экземпляров. Чаще всего это не проблема, но в определённых ситуациях подобный подход к хранению данных может создавать некоторые сложности. Например, представим себе, что существуют два экземпляра некоего компонента. Когда в любом из этих экземпляров меняются данные, эти изменения сказываются и на другом экземпляре. В определённых случаях такое поведение может оказаться нежелательным, может понадобиться, чтобы каждый экземпляр компонента пользовался бы собственной копией данных.

Итоги


Вспомним наш главный вопрос, который заключается в том, стоит ли тратить время и силы на реализацию архитектуры Redux. Мы, в ответ на этот вопрос, говорим Redux «да». Эта архитектура помогает экономить время и силы при разработке и развитии приложений. Использование Redux облегчает жизнь программистов при необходимости частого внесения изменений в приложение, упрощает тестирование. Конечно, архитектура Redux предусматривает наличие немалого объёма шаблонного кода, но она способствует разбиению кода на модули, с которыми удобно работать. Каждый такой модуль может быть протестирован независимо от других, что содействует выявлению ошибок ещё на этапе разработки и позволяет обеспечить высокое качество программ.

Уважаемые читатели! Пользуетесь ли вы Redux в своих проектах?

Комментарии (22)


  1. Listrigon
    16.10.2018 15:41
    +2

    Начал читать, ожидая увидеть что-то эдакое интересное, а тут только рассказали, что есть Redux и… статья закончилась…


  1. DexterHD
    16.10.2018 17:28
    +2

    Архитектура Redux. Да или нет?

    Да, если ваше приложение, мессенджер, продающий одностраничник или TODO лист, и нет во всех остальных случаях imho… :)


    1. anfield343
      16.10.2018 19:32

      у меня нет во всех случаях, имхо.


    1. berman
      16.10.2018 23:30

      Может быть, наоборот? Да — если у ваш дешборд. Нет — если у вас что-то простое типа TODO листа или «продающего одностраничника»


      1. Staltec
        17.10.2018 08:40

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

        Для нас выходом из этого кошмара стал MobX + наша библиотека управления крупными графами моделей: github.com/wearevolt/mobx-model, которая одинаково хорошо подходит как для маленьких, так и очень крупных проектов.


  1. pharrell
    16.10.2018 22:49

    Я уже который год пытаюсь осознать смысл Redux. После прочтения вашей статьи мои сомнения в нём только усугубились.

    1) Почему мы не можем хранить состояние списка друзей в синглтоне FriendsService? При таком подходе данные всегда будут синхронизированы во всех компонентах, использующих список друзей.

    2) Почему запросом данных и передачей их в хранилище занимается компонент списка друзей? Что, если у нас два компонента для рендеринга этого списка — один в разделе «Мои друзья», другой в модальном окне «Поделиться записью», выглядят совершенно по-разному. Каждый компонент должен проверять наличие данных в состоянии, и грузить их, если они отсутствуют? Дублирование кода же.

    3) У нас же real-time приложение. По сокетам прилетает событие, мол, новый друг добавлен в список, или удалён из него. Где это обрабатывать? Самый логичный вариант — этим должен заниматься тот самый синглтон FriendsService.

    В чём преимущество Redux перед описанным мной подходом?

    Кто-нибудь, пожалуйста, ответьте мне на эти вопросы. Я чувствую себя неполноценным членом Front-end сообщества из-за непринятия Redux.


    1. DarthVictor
      16.10.2018 23:23

      1) Почему мы не можем хранить состояние списка друзей в синглтоне FriendsService? При таком подходе данные всегда будут синхронизированы во всех компонентах, использующих список друзей.

      При условии использования PureComponent в качестве контейнера для компонентов это частный случай редаксового стора.

      2) Почему запросом данных и передачей их в хранилище занимается компонент списка друзей? Что, если у нас два компонента для рендеринга этого списка — один в разделе «Мои друзья», другой в модальном окне «Поделиться записью», выглядят совершенно по-разному. Каждый компонент должен проверять наличие данных в состоянии, и грузить их, если они отсутствуют? Дублирование кода же.

      Используйте один контейнер для обработки жизненного цикла и два разных компонента для отображения и подписки на события.

      3) У нас же real-time приложение. По сокетам прилетает событие, мол, новый друг добавлен в список, или удалён из него. Где это обрабатывать? Самый логичный вариант — этим должен заниматься тот самый синглтон FriendsService.

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

      Если сравнивать с вашим подходом — Redux может быть удобен, когда в вашем проекте сложная логика взаимодействия сервисов.


      1. vintage
        17.10.2018 07:07
        +1

        Можно подумать сам редукс не является синглтоном. А можно пример сложной логики?


        1. DarthVictor
          17.10.2018 10:55

          Из последнего. Типичная промышленная «опердень», редактирование записи, свойства полей могут зависеть друг от друг от друга. Редактироваться записи могут с отдельной формы и внутри списка. Сам список также может быть полем формы. При редактировании например связанной записи во вложенном списке не проходит валидация. Нужно передать наверх сообщений об ошибке валидации, поскольку место для показа уведомления при этом — поле родительской записи. Желательно ничего не дублировать, особенно по сравнению с логикой показа ошибки на форме, где только список и где список внутри дашборда.
          Не знаю насколько сложно. На Redux мне было достаточно удобно расшаривать логику из различных мест. На первом Angular тоже делал три года назад схожую вещь — было менее удобно. Возможно еще использование TypeScript помогло.

          Из более раннего. Пользовательское облачное хранилище. Внедрение показа какой-то фичи на основе A/B теста, промо настроек, платных настроек, положения в пользователя внутри этого самого хранилища, обработка ситуации, когда пользователь вообще не залогинен. Вроде вообще не сложно, но определения в виде чистых функций удобнее покрывать тестами. Еще инициализировать это все удобнее на сервере тем же кодом, что и на клиенте. Еще очень хорошо получилось интегрироваться со старым кодом, использующим не React, а свою библиотеку.

          Что характерно — нигде Redux не тормозил. MobX, наверное тоже бы не тормозил. Вот на предыдущем месте работы соседний отдел делал вебверсию почтового клиента. У них тоже ничего не тормозило.


    1. strannik_k
      17.10.2018 07:46

      Хочу прокомментировать. Но я не про Redux буду, т.к. не пользуюсь я им. Просто про схожую архитектуру action -> store -> ui components -> action

      1) Почему мы не можем хранить состояние списка друзей в синглтоне FriendsService? При таком подходе данные всегда будут синхронизированы во всех компонентах, использующих список друзей.
      Из моего опыта разработки клиентской части, что в геймдеве, что в вебе данные удобнее хранить в одном месте (классе) и не писать там больше дополнительного функционала, кроме самого основного (отписки/подписки). И кода меньше и он более однотипный становится.
      И для этого redux не требуется.

      3) У нас же real-time приложение. По сокетам прилетает событие, мол, новый друг добавлен в список, или удалён из него. Где это обрабатывать? Самый логичный вариант — этим должен заниматься тот самый синглтон FriendsService.
      Допустим как и в первом случае, FriendsService только принимает (и отправляет) данные от сервера и сохраняет их в хранилище.
      В простых и средних приложениях так норм делать. FriendsService получает событие с данными и обновляет Store. Я пока не сталкивался со случаями, где такой подход вызывал бы проблемы.
      В больших проектах наверное было бы лучше функции обновления стора вынести вне FriendsService. Т.е. FriendsService только принимает данные и вызывает функции, которые получают данные от FriendsService, преобразовывают их в нужный формат и обновляют хранилище. Такой подход немного похож на редьюсеры redux. Но я сомневаюсь в необходимости этого, т.к. формат принимаемых данных зависит от сервера. Получается что конкретная функция записи данных в стор, за редким исключением может работать только с форматом данных, который используется только конкретным методом FriendsService-а. Тогда зачем их разделять, если они связаны?


      1. pharrell
        17.10.2018 23:57

        Из моего опыта разработки клиентской части, что в геймдеве, что в вебе данные удобнее хранить в одном месте (классе) и не писать там больше дополнительного функционала, кроме самого основного (отписки/подписки)

        А можно конкретные примеры — почему удобнее? Чем централизованное хранилище лучше децентрализованного, разбитого на модули?
        К примеру, наш FriendsService подписан на socket событие friends:state, в котором прилетает состояние списка. Мы стоим перед выбором — записать список в глобальный стор, либо в this.state.
        Почему мы должны выбрать глобальный стор для этой цели?
        Также интересует вопрос — как быть, если приложение разделено на изолированные модули, подгружаемые с помощью LazyLoading? Только что загруженный модуль должен использовать глобальное состояние для хранения своих данных?


        1. strannik_k
          18.10.2018 01:08

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

          К примеру, наш FriendsService подписан на socket событие friends:state, в котором прилетает состояние списка. Мы стоим перед выбором — записать список в глобальный стор, либо в this.state.
          Почему мы должны выбрать глобальный стор для этой цели?
          Если у вас только один компонент использует эти данные, можете использовать this.state. Если же несколько компонентов и им нужно взаимодействовать друг с другом, а также изменять общие данные без отправки на сервер, то тут уже пригодиться глобальный стор.


  1. KhodeN
    16.10.2018 23:07

    Недавно открыл для себя Rematch.
    Просто глоток свежего воздуха. Болейрплейта практически нет. При этом все фичи редакса не теряются, т.к. это всего лишь удобная обертка.
    Главный плюс — не нужно писать кучу ACTION_TYPES, Action Creators, кучу switch в редьюсерах.
    Советую глянуть видео и/или пощупать.


    1. dagen
      17.10.2018 21:33
      +1

      Если у вас настолько маленькие приложения, что «бойлерплейт» лишний — то вам не надо использовать ридакс, это же очевидно. Если ваш техлид, техдир, тимлид, etc., настаивают на ридаксе там, где он не нужен — то тут тоже всё очевидно: проблема не в ридаксе. Если у вас просто наследие, то тут тоже всё просто. Ведь для всякой задачи подходит свой инструмент, а ридакс не серебряная пуля.

      Насчёт кучи switch посмотрите https://www.npmjs.com/package/redux-create-reducer (часто createReducer сами пишут в проектах, благо занимает пару строк).

      Посмотрел Rematch, попробую его, если нужно будет сделать какой-нибудь маленький проектик.


      1. KhodeN
        17.10.2018 21:59

        Возможно, у меня мало опыта с redux. Но его фишку с отдельной сущностью action, которая простой объект, я понять не могу. Какой-то переусложненный опосредованный способ дернуть метод, который может что-то в сторе поменять. Прям SQL или HTTP запрос какой-то. Но там понятно, разные среды исполнения. В SPA приложении-то зачем? Почему не напрямую дернуть метод?
        Единственное, что приходит в голову — пригодится для общения с WebWorker.


        1. mayorovp
          18.10.2018 09:12

          Суть в том, что этот самый action вовсе не обязан быть обработан ровно одним редьюсером. Он может быть обработан двумя разными редьюсерами, может быть трансформирован мидлварью, может быть вовсе никем не обработан.

          А вот если в программе каждый action обрабатывается ровно одним редьюсером — то весь redux и правда вырождается в кучу бойлерплейта, которой можно было бы избежать если выбрать какой-нибудь mobx.


          1. vintage
            18.10.2018 09:37

            Когда что угодно может быть обработано чем угодно (event bus) — сложно становится искать постоянно концы и гарантировать что никакое событие не потеряется и когда-нибудь вообще возникнет.


            1. KhodeN
              18.10.2018 11:35

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


  1. Staltec
    17.10.2018 08:33

    Архитектура Redux. Да или нет?

    MobX


    1. dagen
      17.10.2018 21:34

      Сейчас MobX использую, пришёл к выводу, что это тот же Redux, только вид сбоку. Особой разницы между ними нет.


      1. vintage
        18.10.2018 09:20
        +1

        Если mobx использовать как redux через какой-нибудь mobx-state-tree, то разумеется никакой принципиальной разницы вы не заметите.


  1. Neikist
    17.10.2018 09:43

    Сразу оговорюсь, к вебу отношения не имею, просто посматриваю на флаттер который вроде как немало у реакта перенял но при этом использует нормальный язык со статической типизацией компилирующийся в натив. Так вот, редакс туда тоже прикручивают, но для меня это выглядит перебором. Чем плохо получать данные из стримов (или observable из rx), и туда же их отправлять? Бойлерплейта немного, вьюшки перерендериваются при обновлении данных, можно иметь несколько не связанных хранилищ и вообще более гибко как то все выглядит, и масштабируемо. Особенно если придерживаться подхода с bloc который для флаттера предлагают. Правда есть одно но, я не только к вебу, к мобильной разработке тоже оооочень косвенное отношение имею, может не понимаю чего?