Як в архітектурі Flux ви керуєте життєвим циклом магазину?


132

Я читаю про Flux, але приклад програми Todo занадто спрощений для мене, щоб зрозуміти деякі ключові моменти.

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

Як архітектура Flux відповідатиме магазинам та диспетчерам?

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

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

Відповіді:


124

У додатку Flux має бути лише один диспетчер. Усі дані проходять через цей центральний центр. Наявність одиночного диспетчера дозволяє йому управляти всіма магазинами. Це стає важливим, коли вам потрібно оновити магазин №1, а потім оновити магазин №2, як на основі дії, так і на стані магазину №1. Flux припускає, що ця ситуація є подій у великому застосуванні. В ідеалі така ситуація не повинна відбуватися, і розробники повинні прагнути уникати цієї складності, якщо це можливо. Але одиночний диспетчер готовий впоратися з ним, коли настане час.

Магазини також одинаки. Вони повинні залишатися максимально незалежними і відокремленими - це самодостатня всесвіт, яку можна запитати з перегляду контролера. Єдина дорога до магазину - це зворотний виклик, який він реєструє у диспетчері. Єдина дорога на вулицю - це функції геттера. Магазини також публікують подію, коли їхній стан змінився, тому Controller-Views можуть знати, коли потрібно запитувати новий стан, використовуючи getters.

У вашому прикладі програми буде один PostStore. Цей же магазин може керувати публікаціями на "сторінці" (псевдо-сторінці), що більше нагадує Newsfeed FB, де публікуються публікації від різних користувачів. Його логічним доменом є список публікацій, і він може обробляти будь-який список публікацій. Коли ми переходимо з псевдосторінки на псевдосторінку, ми хочемо повторно ініціалізувати стан магазину для відображення нового стану. Ми також можемо захотіти кешувати попередній стан у localStorage як оптимізацію для переміщення вперед-назад між псевдо-сторінками, але моя схильність полягала б у тому, щоб налаштувати PageStoreте, що чекає всіх інших магазинів, керувати відносинами з localStorage для всіх магазинів на псевдо-сторінку, а потім оновлює власний стан. Зауважте, що це PageStoreнічого не зберігатиме про публікації - це доменPostStore. Було б просто знати, чи певна псевдо-сторінка була кешована, чи ні, тому що псевдосторінки є її доменом.

Був PostStoreби initialize()метод. Цей метод завжди очистить старий стан, навіть якщо це перша ініціалізація, а потім створить стан на основі даних, отриманих в результаті дії, через диспетчер. Перехід від однієї псевдосторінки до іншої, ймовірно, передбачає PAGE_UPDATEдію, яка спровокує виклик initialize(). Існує детальна інформація щодо вилучення даних з локального кешу, пошуку даних із сервера, оптимістичного відображення та станів помилок XHR, але це загальна ідея.

Якщо певній псевдо-сторінці не потрібні всі Магазини в додатку, я не зовсім впевнений, що є якісь причини знищити невикористані, окрім обмежень пам'яті. Але магазини зазвичай не споживають багато пам’яті. Вам просто потрібно переконайтесь, що вилучите слухачів подій з винищуваних контролерів. Це робиться componentWillUnmount()методом React .


5
Звичайно, є кілька різних підходів до того, що ви хочете зробити, і я думаю, це залежить від того, що ви намагаєтеся побудувати. Одним із підходів був би метод UserListStoreіз усіма відповідними користувачами. І кожен користувач матиме декілька булевих прапорів, що описують відношення до поточного профілю користувача. Щось подібне { follower: true, followed: false }, наприклад. Методи getFolloweds()та getFollowers()отримають різні набори користувачів, які вам потрібні для користувальницького інтерфейсу.
fisherwebdev

4
Крім того, ви можете мати FollowedUserListStore та FollowerUserListStore, які успадковують абстрактний UserListStore.
fisherwebdev

У мене невелике запитання - чому б не використовувати паб-підпункт для прямої передачі даних із магазинів, а не вимагати від підписників отримати дані?
sunwukung

2
@sunwukung Це вимагатиме від магазинів відстежувати, які перегляди контролерів потребують, які дані. Чистіше, щоб магазини публікували той факт, що вони якимось чином змінилися, а потім нехай зацікавлені контролери переглядають, які саме частини даних їм потрібні.
fisherwebdev

Що робити, якщо у мене є сторінка профілю, де я показую інформацію про користувача, а також список його друзів. І користувач, і друзі були б однотипними. Чи повинні вони залишитися в одному магазині, якщо так?
Нік Діма

79

(Примітка. Я використовував синтаксис ES6 за допомогою параметра JSX Harmony.)

Як вправу я написав зразок програми Flux, який дозволяє переглядати Github usersта повторно репонувати.
Він заснований на відповіді Fisherwebdev, але також відображає підхід, який я використовую для нормалізації відповідей API.

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

Тут є кілька бітів, які мене особливо зацікавили:

Як класифікувати магазини

Я намагався уникати деяких дублювань, які я бачив в інших прикладах Flux, зокрема в магазинах. Мені було корисно логічно розділити магазини на три категорії:

У Магазинах вмісту містяться всі об’єкти програм. Все, що має ідентифікатор, потребує власного магазину вмісту. Компоненти, які відображають окремі елементи, запитують у магазинах вмісту свіжі дані.

Магазини вмісту збирають свої об’єкти з усіх дій сервера. Наприклад, 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

2
eAbi: Це такий підхід, який ми використовуємо в даний час, коли ми переписуємо штамп у Flux (сподіваючись випустити його наступного місяця). Це не ідеально, але добре працює для нас. Коли / якщо ми знайдемо кращі способи зробити ці речі, ми поділимось ними.
Дан Абрамов

1
eAbi: Однак ми більше не використовуємо normalizr, оскільки хлопець з нашої команди переписав усі наші API, щоб повернути нормалізовані відповіді. Це було корисно і до того, як це було зроблено.
Дан Абрамов

Дякуємо за інформацію! Я перевірив ваше github repo і я намагаюся розпочати проект (побудований в YUI3) з вашим підходом, але у мене виникають проблеми зі складанням коду (якщо ви можете так сказати). Я не запускаю сервер під вузлом, тому мені хотілося скопіювати джерело до мого статичного каталогу, але я все одно повинен виконати певну роботу ... Це трохи громіздко, а також, я знайшов деякі файли, що мають різний синтаксис JS. Особливо у jsx файлах.
eAbi

2
@Sean: Я взагалі не вважаю це проблемою. Потік даних про записи даних, не читаючи його. Звичайно, найкраще, якщо дії є агностиками магазинів, але для оптимізації запитів я думаю, що це цілком чудово читати з магазинів. Зрештою, компоненти читають з магазинів і запускають ці дії. Ви можете повторити цю логіку в кожному компоненті, але це те, для чого створюється дія ..
Дан Абрамов

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.

І це повинно бути майже все.


Дякую за запис!
Дан Абрамов

2
Можливо, трохи пізно на вечірку, але ось приємна стаття, яка пояснює, чому слід уникати дзвінків вам API безпосередньо з магазинів . Я досі розгадую, які найкращі практики є, але я подумав, що це може допомогти іншим спотиканням у цьому. Навколо магазинів плаває багато різних підходів.
Thijs Koerselman
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.