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

132

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

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

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

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

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

Дан Абрамов
источник

Ответы:

124

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

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

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

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

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

fisherwebdev
источник
5
Конечно, есть несколько разных подходов к тому, что вы хотите делать, и я думаю, это зависит от того, что вы пытаетесь построить. Один из подходов - это UserListStoreсо всеми соответствующими пользователями. И у каждого пользователя будет пара логических флагов, описывающих отношение к текущему профилю пользователя. Что-то вроде { follower: true, followed: false }, например. Методы getFolloweds()и getFollowers()будут получать различные наборы пользователей, необходимые для пользовательского интерфейса.
fisherwebdev
4
В качестве альтернативы у вас может быть FollowedUserListStore и FollowerUserListStore, которые наследуются от абстрактного UserListStore.
fisherwebdev
У меня небольшой вопрос - почему бы не использовать pub sub для передачи данных из магазинов напрямую, вместо того, чтобы требовать от подписчиков извлекать данные?
sunwukung
2
@sunwukung Это потребует от хранилищ отслеживать, какие данные и какие представления нужны контроллерам. Будет проще, если магазины будут публиковать тот факт, что они каким-то образом изменились, а затем позволить заинтересованным представлениям контроллера извлекать, какие части данных им нужны.
fisherwebdev 05
Что делать, если у меня есть страница профиля, где я показываю информацию о пользователе, а также список его друзей. И пользователь, и друзья будут одного типа. Должны ли они в таком случае оставаться в одном магазине?
Ник Дима
79

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

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

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

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

Как я классифицирую магазины

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

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

Магазины контента собирают свои объекты в результате всех действий сервера. Например, 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's 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;
}
Дан Абрамов
источник
1
Учитывая тот факт, что вы написали Stampsy, если бы вы переписали все клиентское приложение, использовали бы вы FLUX и тот же подход, который вы использовали для создания этого примера приложения?
eAbi 07
2
eAbi: Это тот подход, который мы сейчас используем, когда переписываем Stampsy in Flux (надеемся выпустить его в следующем месяце). Это не идеально, но нам подходит. Когда / если мы найдем более эффективные способы сделать это, мы поделимся ими.
Дэн Абрамов
1
eAbi: Однако мы больше не используем normalizr, потому что парень из нашей команды переписал все наши API, чтобы возвращать нормализованные ответы. Однако это было полезно до того, как это было сделано.
Дэн Абрамов
Спасибо за Вашу информацию. Я проверил ваше репозиторий на github и пытаюсь начать проект (построенный на YUI3) с вашим подходом, но у меня возникают проблемы с компиляцией кода (если вы можете так сказать). Я не запускаю сервер под узлом, поэтому я хотел скопировать исходный код в свой статический каталог, но мне все еще нужно поработать ... Это немного громоздко, а также я нашел некоторые файлы с другим синтаксисом JS. Особенно в файлах jsx.
eAbi 07
2
@ Шон: Я вообще не вижу в этом проблемы. Поток данных предназначен для записи данных, а не для их чтения. Конечно, лучше всего, если действия не зависят от хранилищ, но для оптимизации запросов я считаю, что чтение из магазинов совершенно нормально. В конце концов, компоненты читают из хранилищ и запускают эти действия. Вы можете повторить эту логику в каждом компоненте, но для этого и нужен создатель действий ..
Дэн Абрамов
27

Таким образом, в 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с правильным идентификатором во время события нажатия.
  • Компонент страницы должен слушать, currentPageStoreчтобы знать, на какую страницу переключиться.
  • Компонент страницы профиля пользователя должен слушать, currentUserProfileStoreчтобы знать, какие данные профиля пользователя отображать.
  • Список сообщений должен прослушивать, currentPostsStoreчтобы получать загруженные сообщения
  • Событие бесконечной прокрутки должно вызывать Action.loadMorePosts.

И это должно быть все.

Spoike
источник
Спасибо, что написали!
Дэн Абрамов
2
Возможно, немного опоздал на вечеринку, но вот хорошая статья, объясняющая, почему не следует вызывать API прямо из магазинов . Я все еще пытаюсь понять, каковы лучшие практики, но я подумал, что это может помочь другим споткнуться об этом. В отношении магазинов существует множество различных подходов.
Thijs Koerselman 02