В архитектуре Flux, как вы управляете жизненным циклом магазина?

Я читаю о Flux, но пример приложения Todo слишком упрощен для меня понять некоторые ключевые моменты.

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

В архитектуре Flux, как это будет соответствовать магазинам и диспетчерам?

Можно ли использовать один PostStore для пользователя, или у нас будет какой-то глобальный магазин? Как насчет диспетчеров, будем ли мы создавать нового диспетчера для каждой "пользовательской страницы", или мы будем использовать синглтон? Наконец, какая часть архитектуры отвечает за управление жизненным циклом "конкретных страниц" в ответ на изменение маршрута?

Кроме того, одна псевдостраница может иметь несколько списков данных того же типа. Например, на странице профиля я хочу показать как последователей, так и последующих. Как может работать singleton UserStore в этом случае? Будет ли UserPageStore управлять followedBy: UserStore и follows: UserStore?

Ответ 1

В приложении Flux должен быть только один диспетчер. Все данные проходят через этот центральный концентратор. Наличие диспетчера Singleton позволяет ему управлять всеми Магазинами. Это становится важным, когда вам нужно обновление Store # 1, а затем обновить Store 2, основываясь как на Action, так и на состоянии Store # 1. Flux предполагает, что эта ситуация является случайностью в большом приложении. В идеале эта ситуация не должна произойти, и разработчики должны стараться избегать этой сложности, если это возможно. Но singleton Dispatcher готов обработать его, когда придет время.

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

В вашем примере приложения будет один PostStore. Этот же магазин может управлять сообщениями на странице (псевдостранице), которая больше похожа на FB Newsfeed, где сообщения появляются у разных пользователей. Его логический домен - это список сообщений, и он может обрабатывать любой список сообщений. Когда мы переходим от псевдо-страницы к псевдо-странице, мы хотим повторно инициализировать состояние хранилища, чтобы отразить новое состояние. Мы могли бы также кэшировать предыдущее состояние в localStorage как оптимизацию для перемещения вперед и назад между псевдостраницами, но моя склонность состояла в том, чтобы настроить PageStore, который ждет все остальные магазины, управляет отношениями с localStorage для всех магазины на псевдостранице, а затем обновляет свое собственное состояние. Обратите внимание, что этот PageStore ничего не будет хранить в сообщениях - в домене PostStore. Он просто будет знать, была ли кеширована конкретная псевдостраница или нет, поскольку псевдостраницы являются ее доменом.

PostStore будет иметь метод initialize(). Этот метод всегда очищает старое состояние, даже если это первая инициализация, а затем создает состояние на основе данных, полученных им через Action, через диспетчер. Переход от одной псевдостраницы к другой, вероятно, будет включать действие PAGE_UPDATE, которое вызовет вызов initialize(). Есть подробные сведения о поиске данных из локального кеша, извлечении данных с сервера, оптимистическом рендеринге и состояниях ошибок XHR, но это общая идея.

Если конкретной псевдостранице не нужны все Хранилища в приложении, я не совсем уверен, что есть причина уничтожить неиспользуемые, кроме ограничений памяти. Но магазины обычно не потребляют много памяти. Вам просто нужно убедиться, что вы удалите прослушиватели событий в объектах Controller-Views, которые вы уничтожаете. Это делается в методе React componentWillUnmount().

Ответ 2

(Примечание. Я использовал синтаксис ES6 с использованием опции JSX Harmony.)

В качестве упражнения я написал пример Flux app, который позволяет просматривать Github users и repos.
Он основан на fisherwebdev answer, но также отражает подход, который я использую для нормализации ответов API.

Я сделал это для документирования нескольких подходов, которые я пробовал при изучении Flux.
Я пытался удержать его в реальном мире (разбиение на страницы, отсутствие поддельных API-интерфейсов LocalStorage).

Есть несколько бит, которые меня особенно интересовали:

  • Он использует Архитектура потока и react-router;
  • Он может отображать страницу пользователя с частичной информацией и информацией о загрузке на ходу;
  • Он поддерживает разбиение на страницы как для пользователей, так и для репозиториев;
  • Он анализирует вложенные ответы JSON Github с Normalizr;
  • Контентные магазины не должны содержать гигантский switch с действиями;
  • "Назад" немедленно (потому что все данные находятся в магазинах).

Как классифицировать магазины

Я попытался избежать частичного дублирования, которое я видел в другом примере Flux, особенно в Stores. Я счел полезным логически разделить магазины на три категории:

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

Контент-магазины собирают свои объекты из всех действий сервера. Например, UserStore смотрит в action.response.entities.users, если он существует независимо от того, какое действие было запущено. Нет необходимости в switch. Normalizr позволяет легко сгладить любые ответы API в этом формате.

// Content Stores keep their data like this
{
  7: {
    id: 7,
    name: 'Dan'
  },
  ...
}

Список магазинов отслеживать идентификаторы объектов, которые отображаются в каком-либо глобальном списке (например, "фид", "ваши уведомления" ). В этом проекте у меня нет таких Магазинов, но я думал, что я упоминаю их в любом случае. Они обрабатывают разбивку на страницы.

Обычно они реагируют на несколько действий (например, REQUEST_FEED, REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

// Paginated Stores keep their data like this
[7, 10, 5, ...]

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

Они также обычно реагируют на несколько действий (например, REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

В большинстве социальных приложений у вас их будет много, и вы захотите быстро создать еще один из них.

// Indexed Paginated Stores keep their data like this
{
  2: [7, 10, 5, ...],
  6: [7, 1, 2, ...],
  ...
}

Примечание: это не настоящие классы или что-то еще; это как раз то, как мне нравится думать о Магазинах. Я сделал несколько помощников, хотя.

StoreUtils

createStore

Этот метод дает вам самый базовый магазин:

createStore(spec) {
  var store = merge(EventEmitter.prototype, merge(spec, {
    emitChange() {
      this.emit(CHANGE_EVENT);
    },

    addChangeListener(callback) {
      this.on(CHANGE_EVENT, callback);
    },

    removeChangeListener(callback) {
      this.removeListener(CHANGE_EVENT, callback);
    }
  }));

  _.each(store, function (val, key) {
    if (_.isFunction(val)) {
      store[key] = store[key].bind(store);
    }
  });

  store.setMaxListeners(0);
  return store;
}

Я использую его для создания всех магазинов.

isInBag, mergeIntoBag

Маленькие помощники, полезные для контент-магазинов.

isInBag(bag, id, fields) {
  var item = bag[id];
  if (!bag[id]) {
    return false;
  }

  if (fields) {
    return fields.every(field => item.hasOwnProperty(field));
  } else {
    return true;
  }
},

mergeIntoBag(bag, entities, transform) {
  if (!transform) {
    transform = (x) => x;
  }

  for (var key in entities) {
    if (!entities.hasOwnProperty(key)) {
      continue;
    }

    if (!bag.hasOwnProperty(key)) {
      bag[key] = transform(entities[key]);
    } else if (!shallowEqual(bag[key], entities[key])) {
      bag[key] = transform(merge(bag[key], entities[key]));
    }
  }
}

PaginatedList

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

class PaginatedList {
  constructor(ids) {
    this._ids = ids || [];
    this._pageCount = 0;
    this._nextPageUrl = null;
    this._isExpectingPage = false;
  }

  getIds() {
    return this._ids;
  }

  getPageCount() {
    return this._pageCount;
  }

  isExpectingPage() {
    return this._isExpectingPage;
  }

  getNextPageUrl() {
    return this._nextPageUrl;
  }

  isLastPage() {
    return this.getNextPageUrl() === null && this.getPageCount() > 0;
  }

  prepend(id) {
    this._ids = _.union([id], this._ids);
  }

  remove(id) {
    this._ids = _.without(this._ids, id);
  }

  expectPage() {
    invariant(!this._isExpectingPage, 'Cannot call expectPage twice without prior cancelPage or receivePage call.');
    this._isExpectingPage = true;
  }

  cancelPage() {
    invariant(this._isExpectingPage, 'Cannot call cancelPage without prior expectPage call.');
    this._isExpectingPage = false;
  }

  receivePage(newIds, nextPageUrl) {
    invariant(this._isExpectingPage, 'Cannot call receivePage without prior expectPage call.');

    if (newIds.length) {
      this._ids = _.union(this._ids, newIds);
    }

    this._isExpectingPage = false;
    this._nextPageUrl = nextPageUrl || null;
    this._pageCount++;
  }
}

PaginatedStoreUtils

createListStore, createIndexedListStore, createListActionHandler

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

var PROXIED_PAGINATED_LIST_METHODS = [
  'getIds', 'getPageCount', 'getNextPageUrl',
  'isExpectingPage', 'isLastPage'
];

function createListStoreSpec({ getList, callListMethod }) {
  var spec = {
    getList: getList
  };

  PROXIED_PAGINATED_LIST_METHODS.forEach(method => {
    spec[method] = function (...args) {
      return callListMethod(method, args);
    };
  });

  return spec;
}

/**
 * Creates a simple paginated store that represents a global list (e.g. feed).
 */
function createListStore(spec) {
  var list = new PaginatedList();

  function getList() {
    return list;
  }

  function callListMethod(method, args) {
    return list[method].call(list, args);
  }

  return createStore(
    merge(spec, createListStoreSpec({
      getList: getList,
      callListMethod: callListMethod
    }))
  );
}

/**
 * Creates an indexed paginated store that represents a one-many relationship
 * (e.g. user posts). Expects foreign key ID to be passed as first parameter
 * to store methods.
 */
function createIndexedListStore(spec) {
  var lists = {};

  function getList(id) {
    if (!lists[id]) {
      lists[id] = new PaginatedList();
    }

    return lists[id];
  }

  function callListMethod(method, args) {
    var id = args.shift();
    if (typeof id ===  'undefined') {
      throw new Error('Indexed pagination store methods expect ID as first parameter.');
    }

    var list = getList(id);
    return list[method].call(list, args);
  }

  return createStore(
    merge(spec, createListStoreSpec({
      getList: getList,
      callListMethod: callListMethod
    }))
  );
}

/**
 * Creates a handler that responds to list store pagination actions.
 */
function createListActionHandler(actions) {
  var {
    request: requestAction,
    error: errorAction,
    success: successAction,
    preload: preloadAction
  } = actions;

  invariant(requestAction, 'Pass a valid request action.');
  invariant(errorAction, 'Pass a valid error action.');
  invariant(successAction, 'Pass a valid success action.');

  return function (action, list, emitChange) {
    switch (action.type) {
    case requestAction:
      list.expectPage();
      emitChange();
      break;

    case errorAction:
      list.cancelPage();
      emitChange();
      break;

    case successAction:
      list.receivePage(
        action.response.result,
        action.response.nextPageUrl
      );
      emitChange();
      break;
    }
  };
}

var PaginatedStoreUtils = {
  createListStore: createListStore,
  createIndexedListStore: createIndexedListStore,
  createListActionHandler: createListActionHandler
};

createStoreMixin

Смеситель, который позволяет компонентам настраиваться на магазины, которые им интересны, например. mixins: [createStoreMixin(UserStore)].

function createStoreMixin(...stores) {
  var StoreMixin = {
    getInitialState() {
      return this.getStateFromStores(this.props);
    },

    componentDidMount() {
      stores.forEach(store =>
        store.addChangeListener(this.handleStoresChanged)
      );

      this.setState(this.getStateFromStores(this.props));
    },

    componentWillUnmount() {
      stores.forEach(store =>
        store.removeChangeListener(this.handleStoresChanged)
      );
    },

    handleStoresChanged() {
      if (this.isMounted()) {
        this.setState(this.getStateFromStores(this.props));
      }
    }
  };

  return StoreMixin;
}

Ответ 3

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

Actions <-- Store { <-- Another Store } <-- Components

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

Actions --> Stores --> Components
   ^          |            |
   +----------+------------+

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

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

В Reflux вы настроили его так:

Действия

// Set up the two actions we need for this use case.
var Actions = Reflux.createActions(['openUserProfile', 'loadUserProfile', 'loadInitialPosts', 'loadMorePosts']);

Хранилище страниц

var currentPageStore = Reflux.createStore({
    init: function() {
        this.listenTo(openUserProfile, this.openUserProfileCallback);
    },
    // We are assuming that the action is invoked with a profileid
    openUserProfileCallback: function(userProfileId) {
        // Trigger to the page handling component to open the user profile
        this.trigger('user profile');

        // Invoke the following action with the loaded the user profile
        Actions.loadUserProfile(userProfileId);
    }
});

Хранилище профиля пользователя

var currentUserProfileStore = Reflux.createStore({
    init: function() {
        this.listenTo(Actions.loadUserProfile, this.switchToUser);
    },
    switchToUser: function(userProfileId) {
        // Do some ajaxy stuff then with the loaded user profile
        // trigger the stores internal change event with it
        this.trigger(userProfile);
    }
});

Хранилище сообщений

var currentPostsStore = Reflux.createStore({
    init: function() {
        // for initial posts loading by listening to when the 
        // user profile store changes
        this.listenTo(currentUserProfileStore, this.loadInitialPostsFor);
        // for infinite posts loading
        this.listenTo(Actions.loadMorePosts, this.loadMorePosts);
    },
    loadInitialPostsFor: function(userProfile) {
        this.currentUserProfile = userProfile;

        // Do some ajax stuff here to fetch the initial posts then send
        // them through the change event
        this.trigger(postData, 'initial');
    },
    loadMorePosts: function() {
        // Do some ajaxy stuff to fetch more posts then send them through
        // the change event
        this.trigger(postData, 'more');
    }
});

Компоненты

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

  • Кнопки, открывающие профиль пользователя, должны вызвать Action.openUserProfile с правильным идентификатором во время события click.
  • Компонент страницы должен прослушивать currentPageStore, чтобы он знал, на какую страницу перейти.
  • Компонент страницы профиля пользователя должен прослушивать currentUserProfileStore, чтобы он знал, какие данные профиля пользователя отображают
  • Список сообщений должен прослушивать currentPostsStore для получения загруженных сообщений
  • Для бесконечного события прокрутки необходимо вызвать Action.loadMorePosts.

И это должно быть в значительной степени.