Redux: шаг за шагом

Redux: шаг за шагом

Nuances of programming

Перевод от Maxon Vislogurov статьи Tal Kol: "Redux Step by Step: A Simple and Robust Workflow for Real Life Apps".


Никаких деревьев, только камни

Продуманная методология для идиоматического Redux

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

Какой подход верный? Единственного и четкого ответа нет. И нет «лучшего» пути использования Redux. Стоит признать, что большой выбор подходов заводит в тупик. Я хочу продемонстрировать свой личный вариант использования библиотеки. Он понятный, применимый к самым разнообразным сценариям из жизни и, что самое главное, он прост в освоении.

Итак, пора создать наше приложение!

Для продолжения нам нужен реальный пример. Давайте создадим приложение, показывающее самые популярные посты с Reddit.

На первом экране мы выясним у пользователя три наиболее интересные для него темы. После того, как пользователь сделает выбор, будем показывать список постов по выбранным темам (все посты либо посты по конкретной теме). По клику на пост в списке будем показывать его содержание.

Установка

Поскольку мы используем React, то для начала работы возьмем Create React App — официальный стартовый шаблон. Также установим reduxreact-reduxи redux-thunk. Результат должен быть похож на этот.

Давайте изменим index.js и создадим в нем стор, подключим санки:

import React from 'react';
import ReactDOM from 'react-dom';
import { createStore, applyMiddleware, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import App from './App';
import './index.css';

import * as reducers from './store/reducers';
const store = createStore(combineReducers(reducers), applyMiddleware(thunk));

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);


Одна из главных вещей, которая часто упускается в различных Redux туториалах: а где же место Redux в этом цикле? Redux является реализацией Flux-архитектуры — паттерна для организации передачи данных в React-приложениях.

В классическом Flux для хранения стейта приложения используется стор. Диспатчинг (передача) экшенов вызывает изменение этого стейта. После этого происходит перерендер представления в соответствии с измененным стейтом:

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

Одна из сложностей в понимании работы Redux — это множество неочевидных терминов типа редюсеровселекторов и санков. Для более четкого понимания взглянем на расширенный Flux-цикл. Это просто различные Redux-инструменты:

Как вы могли заметить другие Redux-инструменты типа миддлваров или саг не показаны. Это сделано намеренно, эти инструменты не играют существенной роли в нашем приложении.

Файловая структура проекта

Создадим корневую папку /src и в ней следующие подкаталоги:

  • /src/components - «глупые» React-компоненты, несвязанные с Redux
  • /src/containers - «умные» React-компоненты, подключаемые к Redux-стору
  • /src/services - некоторые абстракции для внешнего API (например, для бекенда)
  • /src/store - весь специфичный для Redux код находится здесь, включая всю бизнес-логику нашего приложения

Папка store в свою очередь состоит из доменов, которые содержат:

  • /src/store/{domain}/reducer.js - редюсеры, экспортируемые по умолчанию, и селекторы, экспортируемые с помощью именованного экспорта
  • /src/store/{domain}/actions.js - все обработчики экшенов домена (санки и простые объекты)

State-first подход

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

Итак, какой стейт приложения требуется для первой стадии?

Нам нужно будет сохранить список тем, полученных с сервера. Также нужно будет сохранить id выбранных пользователем тем (максимум три id). Будет нелишним сохранить порядок выбора. Например, если, в нашем случае, уже выбрано три темы и пользователь выбирает ещё, то мы будем удалять самую старую из выбранных тем.

Каким образом будет стуктурно организован стейт приложения? В моей предыдущей статье есть список полезных советов — Avoiding Accidental Complexity When Structuring Your App State. Руководствуясь этими советами, мы получим следующую структуру:

{
  "topicsByUrl": {
    "/r/Jokes/": {
      "title": "Jokes",
      "description": "The funniest sub on reddit. Hundreds of jokes posted each day, and some of them aren't even reposts! FarCraft"
    },
    "/r/pics/": {
      "title": "pics",
      "description": "I bet you can figure it out by reading the name of the subreddit"
    }
  },
  "selectedTopicUrls": ["/r/Jokes/"]
}


URL каждой темы будет служить уникальным id.

Где мы будем хранить этот стейт? В Redux есть редюсер (reducer) — это конструкция, хранящая стейт и обновляющая его. Так как наш код будет организован по доменам, то редюсер будет лежать в: /src/store/topics/reducer.js.

Я подготовил шаблон для создания редьюсера, вы можете посмотреть на него здесь. Обратите внимание, что для обеспечения иммутабельности нашего состояния (как того требует Redux), я выбрал библиотеку seamless-immutable.

Наш первый сценарий

После моделирования стейта, мы готовы продолжить реализовывать наше приложение. Давайте создадим компонент, выводящий на экран список тем, как только они появляются. Этот компонент будет подключен к редюсеру, а это означает, что компонент «умный», то есть он использует Redux. Создадим его в /src/containers/TopicsScreen.js.

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

Правило: умные компоненты не должны содержать никакой логики, кроме передачи действий (диспатчинг экшенов).

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

import _ from 'lodash';
import * as types from './actionTypes';
import redditService from '../../services/reddit';

export function fetchTopics() {
  return async(dispatch, getState) => {
    try {
      const subredditArray = await redditService.getDefaultSubreddits();
      const topicsByUrl = _.keyBy(subredditArray, (subreddit) => subreddit.url);
      dispatch({ type: types.TOPICS_FETCHED, topicsByUrl });
    } catch (error) {
      console.error(error);
    }
  };
}


Для удобства работы с API Reddit мы создадим новый сервис, получающий актуальное состояние сети. Это асинхронный метод и для него мы будем использовать await. Мне нравится async/await API, по этой причине я уже давно не использую промисы.

Сервис возвращает нам массив, но наше приложение хранит список тем в виде map. Тело экшена — это хорошее место для преобразования массива в map. Чтобы сохранить данные в сторе, мы должны вызвать наш редьюсер, передав в него объект — TOPICS_FETCHED.

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

Несколько слов о сервисах

Как уже отмечалось ранее, сервисы используются для работы с внешним API, в большинстве случаев с сервер-API, как API Reddit. Плюс от использования сервисов в том, что наш код становится более независимым от изменений API. Если в будущем Reddit решит что-то изменить (конечную точку, названия полей), то эти изменения затронут только наши сервисы, а не всё приложение целиком.

Правило: cервисы должны быть stateless (то есть не должны иметь состояния).

На самом деле, это довольно неочевидное правило в нашей методологии. Представим, что случилось бы, если бы наше API требовало пароль. Мы могли бы сохранить стейт для логина с помощью данных для входа в систему внутри сервиса.

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

Реализация сервиса довольно проста, увидеть её можно здесь.

Завершение сценария — редюсер и представление

Объект TOPICS_FETCHED, содержащий только что полученный список тем topicsByUrl, передается, как аргумент в редюсер. Редюсер не должен делать ничего, кроме сохранения этих данных в стейте:

import * as types from './actionTypes';
import Immutable from 'seamless-immutable';

const initialState = Immutable({
  topicsByUrl: undefined,
  selectedTopicUrls: []
});

export default function reduce(state = initialState, action = {}) {
  switch (action.type) {
    case types.TOPICS_FETCHED:
      return state.merge({
        topicsByUrl: action.topicsByUrl
      });
    default:
      return state;
  }
}


Обратите внимание на использование seamless-immutable. Эта библиотека применяется для того, чтобы сделать наше изменение явным и понятным. Использование таких библиотек не является обязательным, я предпочитаю использовать прием со спред-оператором.

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

import React, { Component } from 'react';
import { connect } from 'react-redux';
import * as topicsActions from '../store/topics/actions';
import * as topicsSelectors from '../store/topics/reducer';

class TopicsScreen extends Component {
  // здесь находится реализация представления
}

// пропсы, которые мы хотим получить из глобального стора
function mapStateToProps(state) {
  return {
    rowsById: topicsSelectors.getTopicsByUrl(state),
    rowsIdArray: topicsSelectors.getTopicsUrlArray(state)
  };
}

export default connect(mapStateToProps)(TopicsScreen);


Я решил, что наше представление будет отображать список тем с помощью отдельного компонента ListView, принимающего пропсы rowsById и rowsIdArray. Внутри TopicsScreen я использую mapStateToProps для обработки этих пропсов (далее они будут передаваться непосредственно в ListView). Пропсы могут быть получены из нашего стейта. Обратите внимание, что я не обращаюсь к стейту напрямую...

Правило: умные компоненты должны обращаться к состоянию только с помощью селекторов.

Селекторы один из самых главных инструментов Redux, про который обычно забывают. Селектор — это чистая функция, принимающая в качестве аргумента глобальный стейт и возвращающая его в преобразованном виде. Селекторы тесно связаны с редюсерами и расположены внутри reducer.js. Селекторы позволяют нам провести некоторые расчеты по данным, прежде чем данные попадут в представление. В будущем мы воспользуемся этим приёмом. Каждый раз, когда нам необходимо получить часть стейта (например в mapStateToProps), мы должны использовать селекторы.

Почему? Идея состоит в том, чтобы инкапсулировать внутренний стейт приложения и скрыть его от представления. Представьте, что позже мы решили изменить внутреннюю структуру. Без селекторов нам пришлось бы вносить изменения в каждый компонент представления, что нежелательно. Использование селекторов позволит проводить рефакторинг, изменяя только редьюсер.

Сейчас topics/reducer.js выглядит так:

import _ from 'lodash';

export default function reduce(state = initialState, action = {}) {
  // здесь реализация редюсера
}

// селекторы

export function getTopicsByUrl(state) {
  return state.topics.topicsByUrl;
}

export function getTopicsUrlArray(state) {
  return _.keys(state.topics.topicsByUrl);
}


Текущую стадию нашего приложения, включая ListView, можно увидеть здесь.

Несколько слов о глупых компонентах

ListView хороший пример глупого компонента. Он не подключен к стору и не использует Redux. В отличие от умных компонентов, глупые расположены в /src/components.

Глупые компоненты получают данные от родителя через пропсы и могут хранить локальный стейт.

Итак, когда же нам надо переходить от умного компонента к глупому?

Правило: вся логика представления в умных компонентах должна выноситься в глупые.

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

Следующий сценарий — выбор нескольких тем

Первый сценарий завершен. Переходим к следующему: пользователь может выбрать только три темы из списка.

Сценарий запускается, когда пользователь кликает по одной их тем. Это событие отлавливается в TopicsScreen, но так как умный компонент не может содержать никакой бизнес-логики, то мы должны диспатчить новый экшен — selectTopic. Этот экшен тоже будет реализован с помощью санка, разместим его в topics/actions.js. Как вы могли заметить, почти каждый экшен, который мы экспортируем (для диспатчинга), - это санк.

export function selectTopic(topicUrl) {
  return (dispatch, getState) => {
    const selectedTopics = topicsSelectors.getSelectedTopicUrls(getState());
    if (_.indexOf(selectedTopics, topicUrl) !== -1) return;
    const newSelectedTopics = selectedTopics.length < 3 ?
      selectedTopics.concat(topicUrl) :
      selectedTopics.slice(1).concat(topicUrl);
    dispatch({ type: types.TOPICS_SELECTED, selectedTopicUrls: newSelectedTopics  });
  };
}


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

Нам нужно будет обновить редюсер таким образом, чтобы он мог обрабатывать TOPICS_SELECTED и сохранять новые выбранные темы. Возникает вопрос, а должен ли selectTopic быть санком? Ведь мы можем сделать selectTopicпростым объектом действия и передать его внутрь редюсера. Это тоже правильный подход. Лично я предпочитаю хранить бизнес-логику в санках.

Обновив стейт, нам нужно вернуть список тем в наше представление. Это значит, что нужно добавить выбранные темы в mapStateToProps. Поскольку представление должно каждый раз запрашивать, выбран ли rowId или нет, то более разумным будет предавать эти данные в виде map. Так как данные будут проходить через селектор в любом случае, то именно там мы и выполним преобразование в map.

Реализуем вышеизложенную идею и добавим смену цвета фона при выборе темы внутри нового глупого компонента — ListRow. На этом этапе разработки наше приложение выглядит так.

Несколько слов о бизнес-логике

Один из принципов хорошей методологии является разделение представления и бизнес-логики. Где на данный момент у нас реализована бизнес-логика?

Вся бизнес-логика находится в папке src/store/. Большая часть реализована в виде санков в actions.js и часть реализована внутри селекторов в reducer.js. Фактически, из этого следует правило:

Правило: вся бизнес-логика должна находиться внутри обработчиков событий (санков), селекторов и редюсеров.

Переход к следующей стадии — список постов

Когда у нас больше одного экрана в приложении, то нам нужна навигация. Зачастую для навигации используется react-router. Я сознательно избегаю маршрутизации, чтобы не усложнять наше приложение. Выбор внешних зависимостей, таких как маршрутизатор, часто отвлекает от основного процесса разработки.

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

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

export function isTopicSelectionValid(state) {
  return state.topics.selectedTopicUrls.length === 3;
}


Полная реализация того, о чем говорилось выше, находится здесь. Далее, чтобы сделать переключатель экранов, нужно превратить App в подключенный компонент, и с помощью mapStateToProps отлавливать изменения selectionFinalized. Более подробно смотрите здесь.

Экран постов — снова state-first

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

Напоминание: задача второй стадии — отобразить список постов, которые можно отфильтровать в зависимости от выбранной темы. Пользователь может кликнуть на пост в списке и увидеть его содержание. Следуя обозначенной ранее структуре, получаем следующее:

{
  "postsById": {
    "57jrtt": {
      "title": "My girlfriend left me because she couldn't handle my OCD.",
      "topicUrl": "/r/Jokes",
      "body": "I told her to close the door five times on her way out.",
    },
    "57l6oa": {
      "title": "Inception style vertical panoramas done with a quadcopter.",
      "topicUrl": "/r/pics",
      "thumbnail": "http://b.thumbs.redditmedia.com/h74JWprM3wljpdBOOpKDxt5sdZWPRtJBVULIobFfCBU.jpg",
      "url": "http://i.imgur.com/d1KUJI8.jpg"
    }
  },
  "currentFilter": "/r/Jokes",
  "currentPostId": "57jrtt"
}


И создаем новый редюсер здесь.

Первый сценарий — список постов без фильтрации

Наш стейт готов! Теперь реализуем упрощенную версию сценария без фильтра.

Нам нужен умный компонент для отображения постов, назовем его PostsScreen, также нам нужно будет диспатчить новый экшен fetchPosts, когда у компонента будет вызван componentDidMount. Экшен будет санком, создадим его в posts/actions.js.

Это все очень похоже на то, что мы делали ранее. Реализация, по традиции, здесь.

В конце санка мы диспатчим простой экшен POSTS_FETCHED, передающий данные в редьюсер. Нужно будет доработать редьюсер, чтобы он мог сохранять данные. Далее нужно будет отобразить список постов в PostsScreen, для этого мы должны подключить mapStateToProps к селектору, который отдаст нам нужную часть стейта. Далее мы можем отобразить список, повторно используя компонент ListView.

Впрочем, ничего нового: реализация на месте.

Следующий сценарий — фильтр постов

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

Особый интерес для нас представляет фильтрация списка постов. В стейте приложения мы сохраняем данные в виде postsById и currentFilter. Из этих данных можно получить отфильтрованный результат, поэтому сохранять в стейте приложения мы его не будем. Бизнес логика будет запускаться в селекторе перед передачей в представление в mapStateToProps. Следовательно, селектор будет выглядеть так:

export function getPosts(state) {
  const currentFilter = state.posts.currentFilter;
  const postsById = state.posts.postsById;
  const postsIdArray = currentFilter === 'all' ?
    _.keys(postsById) :
    _.filter(_.keys(postsById), (postId) => postsById[postId].topicUrl === currentFilter);
  return [postsById, postsIdArray];
}


Полная реализация этого шага здесь.

Последний сценарий — содержание поста

По правде говоря, это самый простой сценарий. В стейте приложения есть переменная currentPostId. Все, что нам нужно, это обновить эту переменную: диспатчить экшен, когда пользователь кликнет по посту. Эта переменная стейта понадобится в PostsScreen, чтобы показывать содержание поста. Это значит, что нам необходимо создать новый селектор для управления этими данными в mapStateToProps.

Реализация здесь.

Все готово

Код выше завершает реализацию нашего приложения. Полная версия приложения доступна на GitHub: https://github.com/wix/react-dataflow-example.

Какие выводы мы сделали:

  • Стейт приложения — это фундаментальная основа, структурирующаяприложение в виде базы данных.
  • Умные компоненты не должны содержать никакой логики, кроме диспатчинга экшенов.
  • Умные компоненты должны получать доступ к стейту только через селекторы.
  • Логика представления в умных компонентах должна выноситься в глупые компоненты.
  • Вся бизнес-логика должна находиться в обработчиках экшенов (санках), селекторах и редюсерах.
  • Сервисы не должны зависеть от стейта.

Помните, что Redux предоставляет большое поле для экспериментов. Существуют подходы отличные от того, что использовали мы. У меня есть друзья, предпочитающие использовать redux-promise-middleware вместо санков и писать бизнес-логику только в редюсерах.

Если вы хотите поделиться своей собственной методологией для решения нашей задачи, не стесняйтесь: делайте PR в репозиторий проекта и мы рассмотрим его.

Report Page