在Flux架构中,如何管理Store的生命周期?

136
我正在了解Flux,但示例Todo应用程序过于简单,无法理解一些关键点。
想象一个类似Facebook的单页应用程序,具有用户个人资料页面。在每个用户个人资料页面上,我们想显示一些用户信息和他们的最新帖子,并进行无限滚动。我们可以从一个用户个人资料页导航到另一个用户个人资料页。
在Flux架构中,这将如何对应Stores和Dispatchers?
我们会为每个用户使用一个PostStore,还是会有某种全局存储?至于分派器,我们会为每个“用户页面”创建一个新的Dispatcher,还是使用单例?最后,架构的哪个部分负责管理响应路由更改的“页面特定”Stores的生命周期?
此外,一个伪页面可能有几个相同类型的数据列表。例如,在个人资料页面上,我想同时显示“关注者”和“正在关注”。在这种情况下,如何让单例的UserStore工作?是UserPageStore管理followedBy: UserStore和follows: UserStore吗?
3个回答

124
在Flux应用程序中,只应该有一个Dispatcher。所有数据都通过这个中央枢纽流动。拥有单例Dispatcher可以让它管理所有的Stores。当您需要Store#1更新自身,然后根据Store#1的状态和Action使Store#2自身更新时,这变得非常重要。在大型应用程序中,Flux假定这种情况是不可避免的。理想情况下,如果可能的话,开发人员应该努力避免这种复杂性。但当这种情况出现时,单例Dispatcher已经准备好处理它。
Stores也是单例的。它们应该尽可能保持独立和解耦--一个可以从控制器视图查询的自包含宇宙。唯一进入Store的道路是通过其与Dispatcher注册的回调函数。唯一走出的路径是通过getter函数。Stores在它们的状态改变时也会发布事件,以便控制器-视图知道何时使用getters查询新状态。
在您的示例应用程序中,将只有一个 PostStore 。同一个store可以管理“页面”(伪页面)上的帖子,就像FB的新闻提要,其中来自不同用户的帖子出现。它的逻辑域是帖子列表,并且它可以处理任何帖子列表。当我们从伪页面移动到伪页面时,我们希望重新初始化存储的状态以反映新的状态。我们还可能希望将先前的状态缓存到localStorage中,作为在伪页面之间来回移动的优化,但我倾向于设置一个 PageStore ,等待所有其他store,管理伪页面上所有store与localStorage的关系,然后更新它自己的状态。请注意,此 PageStore 不会存储有关帖子的任何信息--这是 PostStore 的领域。它只知道某个伪页面是否已被缓存,因为伪页面是其领域。PostStore 将拥有一个 initialize() 方法。此方法将始终清除旧状态,即使这是第一次初始化,并根据通过 Action 通过 Dispatcher 接收到的数据创建状态。从一个伪页面移动到另一个页面可能涉及 PAGE_UPDATE 操作,这将触发调用 initialize()。关于从本地缓存检索数据、从服务器检索数据、乐观渲染和 XHR 错误状态的检索还有一些详细信息需要处理,但这是一般思路。
如果特定的伪页面不需要应用程序中的所有存储库,则我不确定销毁未使用的存储库是否有任何理由,除了内存限制。但通常存储空间不会消耗大量内存。您只需要确保在您要销毁的 Controller-Views 中删除事件侦听器。这是在 React 的 componentWillUnmount() 方法中完成的。

5
你想做的事情有几种不同的方法,我认为这取决于你想要构建什么。一种方法是创建一个“UserListStore”,其中包含所有相关用户。每个用户都会有一些布尔标志来描述他们与当前用户配置文件的关系,例如{ follower: true, followed: false }getFolloweds()getFollowers() 方法将检索您需要显示在用户界面中的不同用户集合。 - fisherwebdev
4
你可以考虑使用一个抽象的UserListStore,并让FollowedUserListStore和FollowerUserListStore都继承它。这样操作也是可行的。 - fisherwebdev
2
@sunwukung 这将需要存储器跟踪哪些控制器视图需要哪些数据。更好的方法是让存储器发布它们以某种方式已更改的事实,然后让感兴趣的控制器视图检索它们需要的数据部分。 - fisherwebdev
如果我有一个个人资料页面,其中显示有关用户信息以及他的朋友列表。用户和朋友都是同一类型的。如果是这样,它们应该保留在同一个存储中吗? - Nick Dima
在“单一调度器”架构中,让我困惑的是如何管理同一对象上的大量调度器事件。你如何避免命名冲突?这看起来就像全局变量的问题一样。 - Dmitri Zaitsev
显示剩余4条评论

79

(注意:我使用了JSX Harmony选项的ES6语法。)

作为练习,我编写了一个示例Flux应用程序,允许浏览Github用户和repos。
它基于fisherwebdev的答案,但也反映了我在规范化API响应方面使用的方法。

我做这件事是为了记录我在学习Flux时尝试过的一些方法。
我试图保持接近现实世界的情况(分页,没有虚假的localStorage API)。

这里有一些我特别感兴趣的东西:

我如何分类Store

我尝试避免在其他Flux示例中看到的一些重复,特别是在Stores中。
我发现将Stores在逻辑上分成三类非常有用:

内容Stores保存所有应用程序实体。具有ID的所有内容都需要自己的Content Store。呈现单个项目的组件从Content Stores请求最新数据。

Content Stores从所有服务器操作中收集它们的对象。例如,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, ...]

索引列表存储(Indexed List Stores)类似于列表存储(List Stores),但它们定义了一对多的关系。例如,“用户的订阅者”、“仓库的点赞者”、“用户的软件库”。它们还处理分页。

通常也会响应少数几个操作(例如: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, ...],
  ...
}

注意:这些不是真正的类或者什么东西;这只是我对Stores的一种思考方式。但我创建了几个辅助工具。

StoreUtils

createStore

这个方法提供给你最基本的Store:

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

这是一个mixin,允许组件关注它们感兴趣的Stores,例如: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:这是我们目前在重写Stampsy中使用的方法(希望下个月发布)。虽然不是理想的,但对我们来说效果很好。如果我们找到更好的方法,我们会分享给大家。 - Dan Abramov
1
eAbi:然而我们不再使用normalizr,因为我们团队中的一位成员重写了所有的API以返回规范化的响应。尽管在那之前它很有用。 - Dan Abramov
谢谢您的信息。我已经检查了您的Github存储库,并尝试使用您的方法开始一个项目(使用YUI3构建),但是我在编译代码时遇到了一些问题(如果可以这么说)。我没有在node下运行服务器,所以我想将源代码复制到我的静态目录中,但我仍然需要做一些工作... 这有点麻烦,而且我发现一些文件具有不同的JS语法。特别是在jsx文件中。 - eAbi
2
@Sean:我完全不认为这是个问题。数据流是关于写入数据,而不是读取数据。当然,最好的情况是行动与存储无关,但为了优化请求,从存储中读取数据是完全可以的。毕竟,组件从存储中读取并触发这些操作。你可以在每个组件中重复这个逻辑,但这就是操作创建者的作用。 - Dan Abramov
显示剩余5条评论

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
2
也许有点晚了,但这里有一篇不错的文章,解释了为什么要避免直接从存储器调用API。我仍在摸索最佳实践是什么,但我认为这可能会帮助其他人。关于存储器,有很多不同的方法正在流传。 - Thijs Koerselman

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接