Со структурами данных, компонентами и интеграцией с Redux

Недавно я создал одностраничное приложение, которое взаимодействует с внутренним сервером JSON API. Я решил использовать React, чтобы глубже понять основы React и то, как каждый инструмент может помочь в создании масштабируемого интерфейса.

Стек этого приложения состоит из:

  • Фронтенд с React / Redux
  • Бэкэнд-сервер JSON API с Sinatra, интегрированный с Postgres для сохранения базы данных
  • Клиент API, извлекающий данные из OMDb API, написанный на Ruby.

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

Примечание: решения, представленные здесь, предназначены только для справки и могут отличаться в зависимости от потребностей вашего приложения. Здесь для демонстрации используется пример приложения OMDb Movie Tracker.

Приложение

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

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

Когда пользователь ищет фильм на главной странице, он выглядит так:

Для простоты в этой статье мы сосредоточимся только на разработке основных функций приложения. Вы также можете перейти к Часть II: Redux серии.

Структура данных

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

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

Объект результата фильма

Один результат фильма будет содержать такую ​​информацию, как название, год, описание и изображение плаката. При этом нам нужно определить объект, который может хранить эти атрибуты:

{
  "title": "Star Wars: Episode IV - A New Hope",
  "year": "1977",
  "plot": "Luke Skywalker joins forces with a Jedi Knight...",
  "poster": "https://m.media-amazon.com/path/to/poster.jpg",
  "imdbID": "tt0076759"
}

Свойство poster - это просто URL-адрес изображения плаката, которое будет отображаться в результатах. Если для этого фильма нет плаката, будет указано «Н / Д», и мы отобразим его в качестве заполнителя. Нам также понадобится атрибут imdbID, чтобы однозначно идентифицировать каждый фильм. Это полезно для определения того, существует ли результат фильма в списке избранного. Позже мы узнаем, как это работает.

Список избранного

Список избранного будет содержать все фильмы, сохраненные как избранные. Список будет выглядеть примерно так:

[
  { title: "Star Wars", year: "1977", ..., rating: 4 },
  { title: "Avatar", year: "2009", ..., rating: 5 }
]

Имейте в виду, что нам нужно будет найти конкретный фильм из списка, а временная сложность для этого подхода составляет O (N). Хотя он отлично работает для небольших наборов данных, представьте, что вам нужно искать фильм в списке избранного, который постоянно растет.

Имея это в виду, я решил использовать хеш-таблицу с ключами как imdbID и значениями как избранные объекты фильма:

{
  tt0076759: {
    title: "Star Wars: Episode IV - A New Hope",
    year: "1977",
    plot: "...",
    poster: "...",
    rating: "4",
    comment: "May the force be with you!",
  },
  tt0499549: {
    title: "Avatar",
    year: "2009",
    plot: "...",
    poster: "...",
    rating: "5",
    comment: "Favorite movie!",
  }
}

Таким образом, мы можем искать фильм в списке избранного в O (1) времени по его imdbID.

Примечание: сложность времени выполнения, вероятно, не имеет значения в большинстве случаев, поскольку наборы данных обычно небольшие на стороне клиента. Мы также собираемся выполнять нарезку и копирование (также операции O (N)) в Redux в любом случае. Но как инженеру полезно знать о возможных оптимизациях, которые мы можем выполнить.

Компоненты

Компоненты лежат в основе React. Нам нужно будет определить, какие из них будут взаимодействовать с магазином Redux, а какие - только для презентации. Мы также можем повторно использовать некоторые из презентационных компонентов. Наша иерархия компонентов будет выглядеть примерно так:

Главная страница

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

SearchContainer получит результат фильма из нашего хранилища Redux, предоставив информацию в качестве реквизита для MovieItem для рендеринга. Он также отправляет действие поиска, когда пользователь отправляет поиск в SearchInputForm. Подробнее о Redux позже.

Добавить в избранное

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

Мы постоянно обновляем его состояние всякий раз, когда пользователь меняет рейтинг или вводит текст в текстовой области комментария. Это полезно для проверки при отправке формы.

RatingForm отвечает за отображение желтых звездочек, когда пользователь нажимает на них. Он также сообщает текущее значение рейтинга в AddFavoriteForm.

Вкладка "Избранное"

Когда пользователь нажимает на вкладку «Избранное», приложение отображает FavoritesContainer.

FavoritesContainer отвечает за получение списка избранного из магазина Redux. Он также отправляет действия, когда пользователь меняет рейтинг или нажимает кнопку «Удалить».

Наши MovieItem и FavoritesInfo - это просто презентационные компоненты, которые получают реквизиты от FavoritesContainer.

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

Redux Store

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

//store.js
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from "redux-thunk";
import search from './reducers/searchReducer';
import favorites from './reducers/favoritesReducer';
import status from './reducers/statusReducer';
export default createStore(
  combineReducers({
    search,
    favorites,
    status
  }),
  {},
  applyMiddleware(thunk)
)

Мы также сразу применим промежуточное ПО Redux Thunk. Мы поговорим об этом подробнее позже. Теперь давайте разберемся, как мы управляем изменениями состояния, когда пользователь отправляет запрос.

Редуктор поиска

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

Мы будем рассматривать «Получить результат поиска» как черный ящик. Позже мы рассмотрим, как это работает с Redux Thunk. Теперь давайте реализуем функцию редуктора.

//searchReducer.js
const initialState = {
  "title": "",
  "year": "",
  "plot": "",
  "poster": "",
  "imdbID": "",
}
export default (state = initialState, action) => {
  if (action.type === 'SEARCH_SUCCESS') {
    state = action.result;
  }
  return state;
}

initialState будет представлять структуру данных, определенную ранее, как единый объект результата фильма. В функции reducer мы обрабатываем действие, при котором поиск был успешным. Если действие запускается, мы просто переназначаем состояние новому объекту результата фильма.

//searchActions.js
export const searchSuccess = (result) => ({
  type: 'SEARCH_SUCCESS', result
});

Мы определяем действие под названием searchSuccess, которое принимает единственный аргумент, объект результата фильма, и возвращает объект действия типа «SEARCH_SUCCESS». Мы отправим это действие после успешного вызова API поиска.

Redux Thunk: поиск

Давайте посмотрим, как работает ранее использованный «Получить результат поиска». Во-первых, нам нужно сделать удаленный вызов API нашему внутреннему серверу API. Когда на запрос поступает успешный ответ JSON, мы отправляем действие searchSuccess вместе с полезной нагрузкой в ​​searchReducer.

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

Для этого мы определяем функцию, которая принимает единственный аргумент title и служит начальным действием поиска. Эта функция отвечает за получение результата поиска и отправку действия searchSuccess:

//searchActions.js
import apiClient from '../apiClient';
...
export function search(title) {
  return (dispatch) => {
    apiClient.query(title)
      .then(response => {
        dispatch(searchSuccess(response.data))
      });
  }
}

Мы настроили наш клиент API заранее, и вы можете узнать больше о том, как я настроил клиент API здесь. Метод apiClient.query просто выполняет запрос AJAX GET к нашему внутреннему серверу и возвращает обещание с данными ответа.

Затем мы можем подключить эту функцию как отправку действия к нашему компоненту SearchContainer:

//SearchContainer.js
import React from 'react';
import { connect } from 'react-redux';
import { search } from '../actions/searchActions';
...
const mapStateToProps = (state) => (
  {
    result: state.search,
  }
);
const mapDispatchToProps = (dispatch) => (
  {
    search(title) {
      dispatch(search(title))
    },
  }
);
export default connect(mapStateToProps, mapDispatchToProps)(SearchContainer);

Когда поисковый запрос выполнен успешно, наш компонент SearchContainer отобразит результат фильма:

Обработка других статусов поиска

Теперь у нас есть действие search, которое работает правильно и подключено к нашему компоненту SearchContainer, мы хотели бы обрабатывать другие случаи, кроме успешного поиска.

Запрос на поиск ожидает рассмотрения

Когда пользователь отправляет поисковый запрос, мы показываем анимацию загрузки, указывающую на то, что поисковый запрос находится на рассмотрении:

Поисковый запрос выполнен успешно

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

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

Редуктор статуса

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

  • В ожидании (когда пользователь впервые инициирует действие)
  • Успех (когда запрос возвращает успешный ответ)
  • Ошибка (когда запрос возвращает ответ с ошибкой)

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

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

// statusReducer.js
const initialState = {
  search: '',      // status of the current search
  searchError: '', // error message when a search fails
}

Далее нам нужно определить функцию редуктора. Каждый раз, когда наш SearchContainer отправляет действие «SEARCH_ [STATUS]», мы обновляем магазин, заменяя свойства search и searchError.

// statusReducer.js
...
export default (state = initialState, action) => {
  const actionHandlers = {
    'SEARCH_REQUEST': {
      search: 'PENDING',
      searchError: '',
    },
    'SEARCH_SUCCESS': {
      search: 'SUCCESS', 
      searchError: '',      
    },
    'SEARCH_FAILURE': {
      search: 'ERROR',
      searchError: action.error, 
    },
  }
  const propsToUpdate = actionHandlers[action.type];
  state = Object.assign({}, state, propsToUpdate);
  return state;
}

Здесь мы используем хеш-таблицу actionHandlers, поскольку мы только заменяем свойства состояния. Кроме того, это улучшает читаемость больше, чем использование операторов if/else или case.

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

Теперь у нас есть дополнительные действия searchRequest и searchFailure, которые можно отправить в магазин:

//searchActions.js
export const searchRequest = () => ({
  type: 'SEARCH_REQUEST'
});
export const searchFailure = (error) => ({
  type: 'SEARCH_FAILURE', error
});

Чтобы обновить действие search, мы немедленно отправим searchRequest и отправим searchSuccess или searchFailure в зависимости от конечного успеха. или невыполнение обещания, возвращенного Axios:

//searchActions.js
...
export function search(title) {
  return (dispatch) => {
    dispatch(searchRequest());
apiClient.query(title)
      .then(response => {
        dispatch(searchSuccess(response.data))
      })
      .catch(error => {
        dispatch(searchFailure(error.response.data))
      });
  }
}

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

//SearchContainer.js
...(imports omitted)
const SearchContainer = (props) => (
  <main id='search-container'>
    <SearchInputForm 
      placeholder='Search movie title...'
      onSubmit={ (title) => props.search(title) }
    />
    {
      (props.searchStatus === 'SUCCESS')
      ? <MovieItem
          movie={ props.result }
          ...(other props)
        />
      : null
    }
    {
      (props.searchStatus === 'PENDING')
      ? <section className='loading'>
          <img src='../../images/loading.gif' />
        </section>
      : null
    }
    {
      (props.searchStatus === 'ERROR')
      ? <section className='error'> 
          <p className='error'>
            <i className="red exclamation triangle icon"></i>
            { props.searchError }
          </p>
        </section>
      : null
    }
  </main>
);
const mapStateToProps = (state) => (
  {
    searchStatus: state.status.search,
    searchError: state.status.searchError,
    result: state.search,
  }
);
...

Избранное Редуктор

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

  • Сохраните фильм в списке избранного
  • Получить все избранные фильмы
  • Обновить рейтинг любимого
  • Удалить фильм из списка избранного

Чтобы гарантировать чистоту функции редуктора, мы просто копируем старое состояние в новый объект вместе с любыми новыми свойствами usingObject.assign. Обратите внимание, что мы обрабатываем только действия с типами _SUCCESS:

//favoritesReducer.js
export default (state = {}, action) => {
  switch (action.type) {
    case 'SAVE_FAVORITE_SUCCESS':
      state = Object.assign({}, state, action.favorite);
      break;
case 'GET_FAVORITES_SUCCESS':
      state = action.favorites;
      break;
case 'UPDATE_RATING_SUCCESS':
      state = Object.assign({}, state, action.favorite);
      break;
case 'DELETE_FAVORITE_SUCCESS':
      state = Object.assign({}, state);
      delete state[action.imdbID];
      break;
default: return state;
  }
  return state;
}

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

С этого момента каждое из избранных действий будет следовать общей последовательности событий, показанной ниже. Шаблон аналогичен поисковому действию в предыдущем разделе, за исключением того, что сейчас мы пропустим обработку любого статуса «ОЖИДАНИЕ».

Сохранить действие в избранном

Возьмем, к примеру, действие сохранения избранного. Функция выполняет вызов API с нашим apiClient и отправляет действие saveFavoriteSuccess или saveFavoriteFailure, в зависимости от того, получим ли мы успешное отклик:

//favoritesActions.js
import apiClient from '../apiClient';
export const saveFavoriteSuccess = (favorite) => ({
  type: 'SAVE_FAVORITE_SUCCESS', favorite
});
export const saveFavoriteFailure = (error) => ({
  type: 'SAVE_FAVORITE_FAILURE', error
});
export function save(movie) {
  return (dispatch) => {
    apiClient.saveFavorite(movie)
      .then(res => {
        dispatch(saveFavoriteSuccess(res.data))
      })
      .catch(err => {
        dispatch(saveFavoriteFailure(err.response.data))
      });
  }
}

Теперь мы можем связать действие save избранное с AddFavoriteForm через React Redux.

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

Заключение

Проектирование внешнего интерфейса приложения требует некоторой предусмотрительности, даже при использовании популярной библиотеки JavaScript, такой как React. Подумав о том, как структуры данных, компоненты, API и управление состоянием работают в целом, мы можем лучше предвидеть крайние случаи и эффективно исправлять ошибки, когда они возникают. Используя определенные шаблоны проектирования, такие как управляемые компоненты, Redux и обработку рабочего процесса AJAX с помощью Thunk, мы можем упростить управление потоком предоставления обратной связи пользовательского интерфейса для действий пользователя. В конечном итоге то, как мы подходим к дизайну, повлияет на удобство использования, ясность и масштабируемость в будущем.

использованная литература

Fullstack React: полное руководство по ReactJS и друзьям

Обо мне

Я программист из Нью-Йорка и соавтор SpaceCraft. У меня есть опыт разработки одностраничных приложений, синхронизации состояния между несколькими клиентами и развертывания масштабируемых приложений с помощью Docker.

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