在Flux架构中,您如何管理商店生命周期?


132

我正在阅读有关Flux的信息,示例Todo应用程序对于我来说太简单了,无法理解一些关键点。

想象一下像Facebook这样的具有用户个人资料页的单页应用程序。在每个用户个人资料页面上,我们要无限滚动地显示一些用户信息及其最新帖子。我们可以从一个用户个人资料导航到另一个。

在Flux体系结构中,这将与商店和调度员相对应吗?

我们会为PostStore每个用户使用一个,还是会有某种全球商店?调度程序呢,我们将为每个“用户页面”创建一个新的调度程序,还是使用单例?最后,体系结构的哪一部分负责管理“特定于页面的”商店的生命周期以响应路线更改?

此外,单个伪页面可能具有相同类型的多个数据列表。例如,个人资料页上,我想同时显示关注跟随UserStore在这种情况下,单例如何工作?将UserPageStore管理followedBy: UserStorefollows: UserStore

Answers:


124

在Flux应用程序中,应该只有一个Dispatcher。所有数据都流经该中央集线器。拥有单例分派器可以使其管理所有商店。当您需要商店1更新自身,然后根据操作和商店1的状态让商店2更新自身时,这一点就变得很重要。Flux假定这种情况在大型应用程序中是偶然的。理想情况下,这种情况将不需要发生,并且开发人员应尽可能避免这种复杂性。但是,当时间到时,单例分派器已准备好处理它。

商店也是单身人士。它们应保持尽可能独立和分离-一个独立的Universe,可以从Controller-View查询。进入商店的唯一途径是通过向分派器注册的回调。唯一的出路是通过getter函数。商店还可以在状态改变时发布事件,因此Controller-Views可以知道何时使用getter查询新状态。

在您的示例应用中,会有一个PostStore。该商店可以在“页面”(伪页面)上管理帖子,该页面更类似于FB的Newsfeed,其中帖子来自不同的用户。它的逻辑域是帖子列表,它可以处理任何帖子列表。当我们从伪页面移到伪页面时,我们想重新初始化存储的状态以反映新状态。我们可能还希望将先前的状态缓存在localStorage中,以作为在伪页面之间来回移动的优化,但是我的意愿是设置一个PageStore等待所有其他存储的,管理所有存储上与localStorage的关系的存储。伪页面,然后更新其自身的状态。请注意,这PageStore将不存储帖子的任何内容,这是PostStore。因为伪页面是它的域,所以它将仅知道是否已缓存了特定的伪页面。

PostStore会有一个initialize()方法。即使这是第一次初始化,此方法也始终会清除旧状态,然后根据通过操作器通过分派器接收到的数据来创建状态。从一个伪页面移动到另一个伪页面可能涉及一个PAGE_UPDATE动作,该动作将触发的调用initialize()。关于从本地缓存中检索数据,从服务器中检索数据,乐观渲染和XHR错误状态,有很多细节需要解决,但这是总的思路。

如果某个特定的伪页面不需要应用程序中的所有商店,那么我不完全确定除了内存限制之外,还有任何理由销毁那些未使用的页面。但是商店通常不会消耗大量内存。您只需要确保在要销毁的Controller-View中删除事件侦听器即可。这是在React的componentWillUnmount()方法中完成的。


5
当然,您要执行的操作有几种不同的方法,我认为这取决于您要构建的内容。一种方法是UserListStore,其中包含所有相关的用户。并且每个用户将具有几个布尔标志,用于描述与当前用户配置文件的关系。喜欢的东西{ follower: true, followed: false },例如。方法getFolloweds()getFollowers()将检索UI所需的不同用户集。
fisherwebdev 2014年

4
或者,您可以具有从抽象UserListStore继承的FollowedUserListStore和FollowerUserListStore。
fisherwebdev 2014年

我有一个小问题-为什么不使用pub sub直接从商店发出数据,而不是要求订阅者检索数据?
sunwukung 2014年

2
@sunwukung这将要求商店跟踪哪些控制器视图需要哪些数据。让商店发布它们已经以某种方式更改的事实是比较干净的,然后让感兴趣的控制器视图检索他们需要的数据的哪些部分。
fisherwebdev 2014年

如果我有一个个人资料页面,在其中我可以显示有关用户的信息以及他的朋友列表,该怎么办。用户和朋友都是同一类型。如果应该,他们应该留在同一家商店吗?
Nick Dima

79

(注意:我已经使用ES6语法和JSX Harmony选项。)

作为练习,我编写了一个示例Flux应用程序,该应用程序可以浏览Github users和存储。
它基于fisherwebdev的答案,但也反映了我用于标准化API响应的方法。

我记录了我在学习Flux时尝试过的几种方法。
我试图使其与现实世界保持紧密联系(分页,没有伪造的localStorage API)。

这里有一些我特别感兴趣的地方:

我如何分类商店

我试图避免在其他Flux示例(尤其是在Stores)中看到的某些重复。我发现从逻辑上将商店分为三类非常有用:

内容商店包含所有应用程序实体。具有ID的所有内容都需要自己的内容存储。呈现单个项目的组件会向内容存储请求新数据。

内容存储区从所有服务器操作中收获其对象。例如,UserStore 眺望action.response.entities.users如果它存在,无论它的动作射击。不需要switch使用Normalizr可以轻松地将任何API响应展平为该格式。

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

列表存储跟踪出现在某些全局列表中的实体的ID(例如“提要”,“您的通知”)。在这个项目中,我没有这样的商店,但是我想我还是会提到它们。他们处理分页。

他们通常只有几个动作做出反应(例如REQUEST_FEEDREQUEST_FEED_SUCCESSREQUEST_FEED_ERROR)。

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

索引列表存储类似于列表存储,但是它们定义了一对多关系。例如,“用户的订户”,“存储库的注视者”,“用户的存储库”。他们还处理分页。

他们也通常只有几个动作做出反应(例如REQUEST_USER_REPOSREQUEST_USER_REPOS_SUCCESSREQUEST_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;
}

我用它来创建所有商店。

isInBagmergeIntoBag

对内容存储有用的小助手。

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

createListStorecreateIndexedListStorecreateListActionHandler

通过提供样板方法和操作处理,使索引列表存储的创建尽可能简单:

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

一个允许组件调入其感兴趣的商店的mixin,例如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 2014年

2
eAbi:这是我们目前正在使用的方法,因为我们正在用Flux重写Stampsy(希望在下个月发布)。这不是理想的方法,但对我们来说效果很好。当/如果我们找到更好的方法来做这些事情,我们将与他们分享。
Dan Abramov 2014年

1
eAbi:但是,我们不再使用normalizr,因为我们团队的一个人重写我们所有的API,以返回标准化的响应。不过,这样做之前很有用。
Dan Abramov 2014年

感谢你的信息。我已经检查了您的github存储库,并尝试使用您的方法开始一个项目(在YUI3中构建),但是在编译代码时遇到了一些麻烦(如果可以的话)。我不在节点下运行服务器,因此我想将源复制到我的静态目录中,但是我仍然需要做一些工作……这有点麻烦,而且我发现一些文件具有不同的JS语法。特别是在jsx文件中。
eAbi 2014年

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在点击事件期间使用正确的ID 调用。
  • 页面组件应该正在监听,currentPageStore以便知道要切换到哪个页面。
  • 用户个人资料页面组件需要进行监听,currentUserProfileStore以便知道要显示哪些用户个人资料数据
  • 帖子列表需要收听currentPostsStore以接收已加载的帖子
  • 无限滚动事件需要调用Action.loadMorePosts

那应该差不多了。


感谢您的来稿!
Dan Abramov 2014年

2
聚会可能有点晚了,但是这里有一篇很好的文章,解释了为什么避免直接从商店调用API。我仍在寻找最佳做法,但是我认为这可能会帮助其他人对此有所绊脚。关于商店,有很多不同的方法。
Thijs Koerselman
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.