Обработка состояния загрузки нескольких асинхронных вызовов в приложении на основе действия/редуктора

Я не думаю, что эта проблема связана с конкретной структурой или библиотекой, но относится ко всем приложениям на основе магазина, следующим шаблону действия - редуктора.

Для ясности я использую Angular и @ngrx.

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

То, как мы обрабатываем другие асинхронные запросы, основано на этом, надеюсь, знакомом шаблоне:

Действия

  • ПОЛУЧИТЬ_РЕСУРС
  • GET_RESOURCE_SUCCESS
  • GET_RESOURCE_FAILURE

Редуктор

switch(action.type)
  case GET_RESOURCE:
    return {
      ...state,
      isLoading = true
    };
  case GET_RESOURCE_SUCCESS:
  case GET_RESOURCE_FAILURE:
    return {
      ...state,
      isLoading = false
    };
  ...

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

В нашем приложении мы извлекаем некоторые данные, скажем, BOOKS, которые содержат список ссылок на другие ресурсы, скажем, CHAPTERS. Если пользователь хочет просмотреть CHAPTER, он щелкает ссылку CHAPTER, которая запускает асинхронный вызов. Чтобы показать пользователю, что эта конкретная CHAPTER загружается, нам нужно нечто большее, чем просто глобальный флаг isLoading в нашем состоянии.

Мы решили эту проблему, создав объект-оболочку следующим образом:

interface AsyncObject<T> {
  id: string;
  status: AsyncStatus;
  payload: T;
}

где AsyncStatus — это перечисление, подобное этому:

enum AsyncStatus {
  InFlight,
  Success,
  Error
}

В нашем состоянии мы храним ГЛАВЫ следующим образом:

{
  chapters: {[id: string]: AsyncObject<Chapter> }
}

Тем не менее, я чувствую, что это каким-то образом «загромождает» состояние, и мне интересно, есть ли у кого-то лучшее решение/другой подход к этой проблеме.

Вопросы

  • Существуют ли какие-либо передовые методы обработки этого сценария?
  • Есть ли лучший способ справиться с этим?

person amu    schedule 27.10.2017    source источник
comment
Вы нашли решение? Вам помог мой ответ?   -  person yuantonito    schedule 03.11.2017


Ответы (1)


Я несколько раз сталкивался с такой ситуацией, но решение различается в зависимости от варианта использования.

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

Другим будет тот, который я подробно описываю ниже.

Исходя из того, что вы описали, ваши полученные данные должны выглядеть так:

  [
    {
      id: 1,
      title: 'Robinson Crusoe',
      author: 'Daniel Defoe',
      references: ['chp1_robincrusoe', 'chp2_robincrusoe'],
    },
    {
      id: 2,
      title: 'Gullivers Travels',
      author: 'Jonathan Swift',
      references: ['chp1_gulliverstravels', 'chp2_gulliverstravels', 'chp3_gulliverstravels'],
    },
  ]

Итак, согласно вашим данным, ваши редукторы должны выглядеть так:

  {
    books: {
      isFetching: false,
      isInvalidated: false,
      selectedBook: null,
      data: {
        1: { id: 1, title: 'Robinson Crusoe', author: 'Daniel Defoe' },
        2: { id: 2, title: 'Gullivers Travels', author: 'Jonathan Swift' },
      }
    },

    chapters: {
      isFetching: false,
      isInvalidated: true,
      selectedChapter: null,
      data: {
        'chp1_robincrusoe': { isFetching: false, isInvalidated: true, id: 'chp1_robincrusoe', bookId: 1, data: null },
        'chp2_robincrusoe': { isFetching: false, isInvalidated: true, id: 'chp2_robincrusoe', bookId: 1, data: null },
        'chp1_gulliverstravels': { isFetching: false, isInvalidated: true, id: 'chp1_gulliverstravels', bookId: 2, data: null },
        'chp2_gulliverstravels': { isFetching: false, isInvalidated: true, id: 'chp2_gulliverstravels', bookId: 2, data: null },
        'chp3_gulliverstravels': { isFetching: false, isInvalidated: true, id: 'chp3_gulliverstravels', bookId: 2, data: null },
      },
    }
  }

С этой структурой вам не понадобятся isFetching и isInvalidated в редукторах глав, поскольку каждая глава представляет собой отдельную логику.

Примечание. Позже я мог бы дать вам дополнительную информацию о том, как мы можем использовать isFetching и isInvalidated по-другому.

Ниже подробный код:


Компоненты

Список книг

  import React from 'react';    
  import map from 'lodash/map';

  class BookList extends React.Component {
    componentDidMount() {
      if (this.props.isInvalidated && !this.props.isFetching) {
        this.props.actions.readBooks();
      }
    }

    render() {
      const {
        isFetching,
        isInvalidated,
        data,
      } = this.props;

      if (isFetching || (isInvalidated && !isFetching)) return <Loading />;
      return <div>{map(data, entry => <Book id={entry.id} />)}</div>;
    }
  }

Забронировать

import React from 'react';
import filter from 'lodash/filter';
import { createSelector } from 'reselect';
import map from 'lodash/map';
import find from 'lodash/find';

class Book extends React.Component {
  render() {
    const {
      dispatch,
      book,
      chapters,
    } = this.props;

    return (
      <div>
        <h3>{book.title} by {book.author}</h3>
        <ChapterList bookId={book.id} />
      </div>
    );
  }
}

const foundBook = createSelector(
  state => state.books,
  (books, { id }) => find(books, { id }),
);

const mapStateToProps = (reducers, props) => {
  return {
    book: foundBook(reducers, props),
  };
};

export default connect(mapStateToProps)(Book);

Список глав

  import React from 'react';
  import { connect } from 'react-redux';
  import { createSelector } from 'reselect';
  import map from 'lodash/map';
  import find from 'lodash/find';

  class ChapterList extends React.Component {
    render() {
      const { dispatch, chapters } = this.props;
      return (
        <div>
          {map(chapters, entry => (
            <Chapter
              id={entry.id}
              onClick={() => dispatch(actions.readChapter(entry.id))} />
          ))}
        </div>
      );
    }
  }

  const bookChapters = createSelector(
    state => state.chapters,
    (chapters, bookId) => find(chapters, { bookId }),
  );

  const mapStateToProps = (reducers, props) => {
    return {
      chapters: bookChapters(reducers, props),
    };
  };

  export default connect(mapStateToProps)(ChapterList);

Глава

  import React from 'react';
  import { connect } from 'react-redux';
  import { createSelector } from 'reselect';
  import map from 'lodash/map';
  import find from 'lodash/find';

  class Chapter extends React.Component {
    render() {
      const { chapter, onClick } = this.props;

      if (chapter.isFetching || (chapter.isInvalidated && !chapter.isFetching)) return <div>{chapter.id}</div>;

      return (
        <div>
          <h4>{chapter.id}<h4>
          <div>{chapter.data.details}</div>  
        </div>
      );
    }
  }

  const foundChapter = createSelector(
    state => state.chapters,
    (chapters, { id }) => find(chapters, { id }),
  );

  const mapStateToProps = (reducers, props) => {
    return {
      chapter: foundChapter(reducers, props),
    };
  };

  export default connect(mapStateToProps)(Chapter);

Действия с книгами

  export function readBooks() {
    return (dispatch, getState, api) => {
      dispatch({ type: 'readBooks' });
      return fetch({}) // Your fetch here
        .then(result => dispatch(setBooks(result)))
        .catch(error => dispatch(addBookError(error)));
    };
  }

  export function setBooks(data) {
    return {
      type: 'setBooks',
      data,
    };
  }

  export function addBookError(error) {
    return {
      type: 'addBookError',
      error,
    };
  }

Действия главы

  export function readChapter(id) {
    return (dispatch, getState, api) => {
      dispatch({ type: 'readChapter' });
      return fetch({}) // Your fetch here - place the chapter id
        .then(result => dispatch(setChapter(result)))
        .catch(error => dispatch(addChapterError(error)));
    };
  }

  export function setChapter(data) {
    return {
      type: 'setChapter',
      data,
    };
  }

  export function addChapterError(error) {
    return {
      type: 'addChapterError',
      error,
    };
  }

Редукторы книги

  import reduce from 'lodash/reduce';
  import { combineReducers } from 'redux';

  export default combineReducers({
    isInvalidated,
    isFetching,
    items,
    errors,
  });

  function isInvalidated(state = true, action) {
    switch (action.type) {
      case 'invalidateBooks':
        return true;
      case 'setBooks':
        return false;
      default:
        return state;
    }
  }

  function isFetching(state = false, action) {
    switch (action.type) {
      case 'readBooks':
        return true;
      case 'setBooks':
        return false;
      default:
        return state;
    }
  }

  function items(state = {}, action) {
    switch (action.type) {
      case 'readBook': {
        if (action.id && !state[action.id]) {
          return {
            ...state,
            [action.id]: book(undefined, action),
          };
        }

        return state;
      }
      case 'setBooks':
        return {
          ...state,
          ...reduce(action.data, (result, value, key) => ({
            ...result,
            [key]: books(value, action),
          }), {});
        },
      default:
        return state;
    }
  }

  function book(state = {
    isFetching: false,
    isInvalidated: true,

    id: null,
    errors: [],
  }, action) {
    switch (action.type) {
      case 'readBooks':
        return { ...state, isFetching: true };
      case 'setBooks':
        return {
          ...state,
          isInvalidated: false,
          isFetching: false,
          errors: [],
        };
      default:
        return state;
    }
  }

  function errors(state = [], action) {
    switch (action.type) {
      case 'addBooksError':
        return [
          ...state,
          action.error,
        ];
      case 'setBooks':
      case 'setBooks':
        return state.length > 0 ? [] : state;
      default:
        return state;
    }
  }

Редукторы глав

Обратите особое внимание на setBooks, с которого будут начинаться главы в ваших редьюсерах.

  import reduce from 'lodash/reduce';
  import { combineReducers } from 'redux';

  const defaultState = {
    isFetching: false,
    isInvalidated: true,
    id: null,
    errors: [],
  };

  export default combineReducers({
    isInvalidated,
    isFetching,
    items,
    errors,
  });

  function isInvalidated(state = true, action) {
    switch (action.type) {
      case 'invalidateChapters':
        return true;
      case 'setChapters':
        return false;
      default:
        return state;
    }
  }

  function isFetching(state = false, action) {
    switch (action.type) {
      case 'readChapters':
        return true;
      case 'setChapters':
        return false;
      default:
        return state;
    }
  }

  function items(state = {}, action) {
    switch (action.type) {
      case 'setBooks':
        return {
          ...state,
          ...reduce(action.data, (result, value, key) => ({
            ...result,
            ...reduce(value.references, (res, chapterKey) => ({
              ...res,
              [chapterKey]: chapter({ ...defaultState, id: chapterKey, bookId: value.id }, action),
            }), {}),
          }), {});
        };
      case 'readChapter': {
        if (action.id && !state[action.id]) {
          return {
            ...state,
            [action.id]: book(undefined, action),
          };
        }

        return state;
      }
      case 'setChapters':
        return {
          ...state,
          ...reduce(action.data, (result, value, key) => ({
            ...result,
            [key]: chapter(value, action),
          }), {});
        },
      default:
        return state;
    }
  }

  function chapter(state = { ...defaultState }, action) {
    switch (action.type) {
      case 'readChapters':
        return { ...state, isFetching: true };
      case 'setChapters':
        return {
          ...state,
          isInvalidated: false,
          isFetching: false,
          errors: [],
        };
      default:
        return state;
    }
  }

  function errors(state = [], action) {
    switch (action.type) {
      case 'addChaptersError':
        return [
          ...state,
          action.error,
        ];
      case 'setChapters':
      case 'setChapters':
        return state.length > 0 ? [] : state;
      default:
        return state;
    }
  }

Надеюсь, поможет.

person yuantonito    schedule 27.10.2017
comment
Я не уверен, что мне нравится такой подход. Я думаю, что это делает редуктор setBooks слишком сложным. Почему setBooks редукторы должны нести ответственность за создание глав? Что, если я прочитаю только одну из более чем 100 глав? Кроме того, модель данных для каждой главы (в chapters.data) в основном имеет ту же структуру, что и у меня. - person amu; 03.11.2017
comment
В этом весь смысл readBook или readChapter, где вы можете получить только одни данные. Я считаю, что самое главное здесь — это философия, стоящая за этим. Вы можете получить все главы в определенный момент, но вы все равно можете аннулировать chapter или book, чтобы повторно загрузить их. - person yuantonito; 03.11.2017