【问题标题】:In Flux architecture, how do you manage Store lifecycle?在 Flux 架构中,如何管理 Store 生命周期?
【发布时间】:2014-06-28 18:29:25
【问题描述】:

我正在阅读有关 Flux 的信息,但 example Todo app 过于简单,我无法理解一些关键点。

想象一个像 Facebook 这样的单页应用,它有用户个人资料页面。在每个用户个人资料页面上,我们希望无限滚动显示一些用户信息和他们最近的帖子。我们可以从一个用户配置文件导航到另一个用户配置文件。

在 Flux 架构中,这将如何对应于 Stores 和 Dispatchers?

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

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

【问题讨论】:

    标签: javascript facebook architecture reactjs reactjs-flux


    【解决方案1】:

    在 Flux 应用中应该只有一个 Dispatcher。所有数据都流经这个中心枢纽。拥有一个单例 Dispatcher 允许它管理所有 Store。当您需要 Store #1 更新自身,然后让 Store #2 根据 Action 和 Store #1 的状态更新自身时,这一点变得很重要。 Flux 假设这种情况是大型应用程序中的一种可能性。理想情况下,这种情况不需要发生,如果可能,开发人员应该努力避免这种复杂性。但是单例 Dispatcher 已准备好在时机成熟时处理它。

    商店也是单例的。它们应该尽可能保持独立和解耦——一个可以从控制器视图查询的自包含宇宙。进入 Store 的唯一途径是通过它向 Dispatcher 注册的回调。唯一的出路是通过 getter 函数。 Stores 也会在状态发生变化时发布事件,因此 Controller-Views 可以知道何时使用 getter 查询新状态。

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

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

    如果一个特定的伪页面不需要应用程序中的所有 Store,我不完全确定是否有任何理由销毁未使用的,除了内存限制。但是存储通常不会消耗大量内存。您只需要确保删除要销毁的控制器视图中的事件侦听器。这是在 React 的 componentWillUnmount() 方法中完成的。

    【讨论】:

    • 肯定有几种不同的方法可以实现您想要做的事情,我认为这取决于您要构建的内容。一种方法是UserListStore,其中包含所有相关用户。每个用户都会有几个布尔标志来描述与当前用户配置文件的关系。例如,{ follower: true, followed: false } 之类的东西。 getFolloweds()getFollowers() 方法将检索 UI 所需的不同用户集。
    • 另外,你可以有一个 FollowedUserListStore 和一个 FollowerUserListStore 都继承自一个抽象的 UserListStore。
    • 我有一个小问题 - 为什么不使用 pub sub 直接从商店发出数据,而不是要求订阅者检索数据?
    • @sunwukung 这将要求商店跟踪哪些控制器视图需要哪些数据。让商店发布他们以某种方式发生变化的事实,然后让感兴趣的控制器视图检索他们需要的数据的哪些部分,这样会更干净。
    • 如果我有一个个人资料页面,其中显示有关用户的信息以及他的朋友列表,该怎么办。用户和朋友都是同一类型。如果是,他们应该留在同一家商店吗?
    【解决方案2】:

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

    作为练习,我写了一个 sample Flux app 允许浏览 Github users 和 repos。
    它基于fisherwebdev's answer,但也反映了我用于规范化 API 响应的一种方法。

    我记录了一些我在学习 Flux 时尝试过的方法。
    我试图让它接近现实世界(分页,没有虚假的 localStorage API)。

    这里有几点我特别感兴趣:

    我如何对商店进行分类

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

    内容商店包含所有应用实体。任何有 ID 的东西都需要有自己的 Content Store。呈现单个项目的组件向内容存储请求新数据。

    内容存储从所有服务器操作中获取他们的对象。例如,UserStorelooks into action.response.entities.users,如果它存在无论哪个动作被触发。不需要switchNormalizr 可以轻松地将任何 API 响应扁平化为这种格式。

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

    List Stores 跟踪出现在某个全局列表中的实体的 ID(例如“feed”、“your notification”)。在这个项目中,我没有这样的商店,但我想我还是会提到它们。他们处理分页。

    他们通常只响应一些操作(例如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

    这个方法给你最基本的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

    允许组件调入他们感兴趣的 Store 的 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;
    }
    

    【讨论】:

    • 鉴于您已经编写了 Stampsy,如果您要重写整个客户端应用程序,您会使用 FLUX 以及您用于构建此示例应用程序的相同方法吗?
    • eAbi:这是我们目前正在使用的方法,因为我们正在用 Flux 重写 Stampsy(希望下个月发布)。这并不理想,但对我们来说效果很好。当/如果我们找到更好的方法来做这些事情,我们会分享它们。
    • eAbi:但是我们不再使用 normalizr,因为我们团队中的一个人重写了我们的 所有 API 以返回标准化响应。不过在完成之前它很有用。
    • 感谢您的信息。我已经检查了您的 github 存储库,并且正在尝试使用您的方法开始一个项目(内置于 YUI3),但是我在编译代码时遇到了一些麻烦(如果您可以这么说的话)。我没有在节点下运行服务器,所以我想将源代码复制到我的静态目录但我仍然需要做一些工作......这有点麻烦,而且我发现一些文件具有不同的 JS 语法。尤其是在 jsx 文件中。
    • @Sean:我根本不认为这是一个问题。 数据流是关于写入数据,而不是读取数据。当然最好是动作与商店无关,但为了优化请求,我认为从商店中读取是非常好的。毕竟,组件从存储中读取并触发这些操作。你可以在每个组件中重复这个逻辑,但这就是 action creator 的用途..
    【解决方案3】:

    所以在Reflux 中,Dispatcher 的概念被删除了,您只需要考虑通过动作和存储的数据流。即

    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');
        }
    });
    

    组件

    我假设您有一个用于整个页面视图、用户个人资料页面和帖子列表的组件。以下需要连接:

    • 打开用户配置文件的按钮需要在点击事件期间调用具有正确 ID 的 Action.openUserProfile
    • 页面组件应该监听currentPageStore,以便知道要切换到哪个页面。
    • 用户配置文件页面组件需要监听currentUserProfileStore,以便知道要显示哪些用户配置文件数据
    • 帖子列表需要收听currentPostsStore才能接收加载的帖子
    • 无限滚动事件需要调用Action.loadMorePosts

    应该差不多了。

    【讨论】:

    • 感谢您的报道!
    • 可能有点晚了,但这里有一个很好的article 解释为什么避免直接从商店调用 API。我仍在弄清楚最佳实践是什么,但我认为这可能有助于其他绊脚石。关于商店,有很多不同的方法。
    猜你喜欢
    • 1970-01-01
    • 2012-11-24
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2010-09-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多