Flux 아키텍처에서 스토어 라이프 사이클을 어떻게 관리합니까?


132

Flux 에 대해 읽고 있지만 Todo 앱 예제 는 너무 간단하여 몇 가지 핵심 사항을 이해하기 어렵습니다.

사용자 프로필 페이지 가있는 Facebook과 같은 단일 페이지 앱을 상상해보십시오 . 각 사용자 프로필 페이지에서 무한 스크롤과 함께 일부 사용자 정보와 마지막 게시물을 표시하려고합니다. 한 사용자 프로필에서 다른 사용자 프로필로 이동할 수 있습니다.

Flux 아키텍처에서 이것이 스토어 및 디스패처와 어떻게 일치합니까?

우리 PostStore는 사용자 당 하나를 사용 합니까 아니면 글로벌 상점이 있습니까? 디스패처는 어떻습니까? 각 "사용자 페이지"마다 새 디스패처를 만들거나 싱글 톤을 사용합니까? 마지막으로, 경로 변경에 대한 응답으로 "페이지 별"상점의 수명주기를 관리하는 아키텍처의 어느 부분이 있습니까?

또한 단일 의사 페이지에는 동일한 유형의 여러 데이터 목록이있을 수 있습니다. 예를 들어, 프로필 페이지에서 추종자팔로우를 모두 표시하고 싶습니다 . UserStore이 경우 싱글 톤은 어떻게 작동합니까? 겠습니까 UserPageStore관리 followedBy: UserStorefollows: UserStore?

답변:


124

Flux 앱에는 디스패처가 하나만 있어야합니다. 모든 데이터는이 중앙 허브를 통해 흐릅니다. 싱글 톤 디스패처가 있으면 모든 상점을 관리 할 수 ​​있습니다. 이는 상점 # 1 자체 업데이트가 필요한 경우 조치 및 상점 # 1의 상태에 따라 상점 # 2 자체를 갱신하도록 할 때 중요합니다. Flux는 이러한 상황이 대규모 응용 프로그램에서 결국 발생한다고 가정합니다. 이상적으로는 이러한 상황이 발생할 필요가 없으며 개발자는 가능하면 이러한 복잡성을 피하기 위해 노력해야합니다. 그러나 싱글 톤 디스패처는 때가되면 처리 할 준비가되어 있습니다.

상점도 싱글 톤입니다. Controller-View에서 쿼리 할 수있는 독립적 인 유니버스로서 가능한 한 독립적이고 분리되어 있어야합니다. 상점으로가는 유일한 길은 Dispatcher에 등록 된 콜백을 통하는 것입니다. 유일한 길은 게터 기능을 통해서입니다. 또한 저장소는 상태가 변경되면 이벤트를 게시하므로 컨트롤러 뷰는 getter를 사용하여 새 상태를 쿼리 할시기를 알 수 있습니다.

예제 앱에는 단일이 있습니다 PostStore. 동일한 상점에서 FB의 뉴스 피드와 유사한 "페이지"(의사 페이지)에서 게시물을 관리 할 수 ​​있습니다.이 게시물은 다른 사용자의 게시물입니다. 논리 도메인은 게시물 목록이며 모든 게시물 목록을 처리 할 수 ​​있습니다. 의사 페이지에서 의사 페이지로 이동할 때 새 상태를 반영하도록 상점 상태를 다시 초기화하려고합니다. 의사 페이지 간을 앞뒤로 이동하는 최적화로 localStorage의 이전 상태를 캐시하고 싶을 수도 있지만 PageStore다른 모든 상점을 기다리는 모든 저장소의 localStorage와의 관계를 관리하는 설정을하는 것이 좋습니다. 의사 페이지를 표시 한 다음 자체 상태를 업데이트합니다. 이것은 PageStore게시물에 대해서는 아무 것도 저장하지 않습니다.PostStore. 의사 페이지가 도메인이기 때문에 특정 의사 페이지가 캐시되었는지 여부 만 알 수 있습니다.

PostStore것이다 initialize()방법을. 이 메소드는 이것이 첫 번째 초기화 인 경우에도 항상 이전 상태를 지우고 Dispatcher를 통해 Action을 통해 수신 한 데이터를 기반으로 상태를 작성합니다. 하나의 의사 페이지에서 다른 의사 페이지로 이동하는 PAGE_UPDATE작업 은 아마도 호출을 유발 하는 작업을 포함 할 것입니다 initialize(). 로컬 캐시에서 데이터 검색, 서버에서 데이터 검색, 낙관적 렌더링 및 XHR 오류 상태에 대해 해결해야 할 세부 사항이 있지만 이것이 일반적인 아이디어입니다.

특정 의사 페이지에 응용 프로그램의 모든 저장소가 필요하지 않은 경우 메모리 제약 조건 이외의 사용되지 않은 저장소를 폐기해야 할 이유가 있는지 확실하지 않습니다. 그러나 상점은 일반적으로 많은 양의 메모리를 소비하지 않습니다. 파괴하려는 Controller-Views에서 이벤트 리스너를 제거해야합니다. 이것은 React의 componentWillUnmount()방법으로 수행됩니다 .


5
당신이하고 싶은 것에 대해 몇 가지 다른 접근 방식이 있으며, 나는 그것이 당신이 만들고자하는 것에 달려 있다고 생각합니다. 하나의 접근 방식은 UserListStore모든 관련 사용자를 포함하는 것입니다. 그리고 각 사용자에게는 현재 사용자 프로필과의 관계를 설명하는 두 개의 부울 플래그가 있습니다. 같은 뭔가 { follower: true, followed: false }예를 들어,. 메서드 getFolloweds()getFollowers()UI에 필요한 다른 사용자 집합을 검색합니다.
fisherwebdev

4
또는 추상 UserListStore에서 상속되는 FollowedUserListStore 및 FollowerUserListStore가있을 수 있습니다.
fisherwebdev

작은 질문이 있습니다-구독자가 데이터를 검색하지 않고 pub sub를 사용하여 매장에서 직접 데이터를 내 보내지 않는 이유는 무엇입니까?
sunwukung 2016 년

2
@sunwukung이를 위해서는 저장소에서 어떤 컨트롤러 뷰에 어떤 데이터가 필요한지 추적해야합니다. 상점이 어떤 식 으로든 변경되었다는 사실을 공개 한 다음 관심있는 컨트롤러보기가 필요한 데이터 부분을 검색하도록하는 것이 더 깨끗합니다.
fisherwebdev

사용자에 대한 정보와 친구 목록을 보여주는 프로필 페이지가있는 경우 어떻게해야합니까? 사용자와 친구 모두 같은 유형입니다. 그렇다면 같은 매장에 있어야합니까?
Nick Dima

79

(참고 : JSX Harmony 옵션을 사용하여 ES6 구문을 사용했습니다.)

연습 으로 브라우징 및 리 포지션 을 허용 하는 샘플 Flux 앱 을 작성했습니다 Github users.
그것은 fisherwebdev의 답변을 기반으로 하지만 API 응답을 정규화하는 데 사용하는 접근법을 반영합니다.

Flux를 배우는 동안 시도한 몇 가지 접근법을 문서화했습니다.
나는 그것을 현실 세계에 가깝게 유지하려고 노력했다 (매김, 가짜 localStorage API 없음).

여기에 특히 관심이있는 몇 가지 비트가 있습니다.

상점을 분류하는 방법

다른 Flux 예제, 특히 Stores에서 본 중복을 피하려고했습니다. 논리적으로 상점을 세 가지 범주로 나누는 것이 유용하다는 것을 알았습니다.

컨텐츠 저장소 는 모든 앱 엔티티를 보유합니다. ID가있는 모든 것은 자체 컨텐츠 저장소가 필요합니다. 개별 항목을 렌더링하는 구성 요소는 Content Store에 최신 데이터를 요청합니다.

컨텐츠 저장소는 모든 서버 조치 에서 오브젝트를 수집 합니다. 예를 들어, UserStore 에 보이는action.response.entities.users 존재하는 경우 에 관계없이 작업이 발사있는. 필요가 없습니다 switch. Normalizr를 사용하면 API 응답을이 형식으로 쉽게 병합 할 수 있습니다.

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

목록 저장소 는 일부 전체 목록에 나타나는 엔티티의 ID (예 : "피드", "알림")를 추적합니다. 이 프로젝트에는 그런 상점이 없지만 어쨌든 언급 할 것이라고 생각했습니다. 그들은 페이지 매김을 처리합니다.

그들은 일반적으로 (예를 들어, 몇 가지의 행동에 반응 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로 다시 작성할 때 현재 사용하고있는 접근 방식입니다 (다음 달에 출시 예정). 이상적이지는 않지만 우리에게 잘 작동합니다. 우리가 그 일을하는 더 좋은 방법을 찾으면 우리는 그것들을 공유 할 것입니다.
Dan Abramov

1
eAbi :하지만 우리 팀은 표준화 된 응답을 반환하기 위해 모든 API를 다시 작성했기 때문에 더 이상 normalizr을 사용하지 않습니다 . 그 전에는 유용했습니다.
Dan Abramov

정보 주셔서 감사합니다. github repo를 확인했으며 접근 방식으로 프로젝트 (YUI3로 빌드)를 시작하려고하는데 코드를 컴파일하는 데 문제가 있습니다 (그렇게 말할 수있는 경우). 노드에서 서버를 실행하지 않으므로 소스를 정적 ​​디렉토리에 복사하고 싶었지만 여전히 약간의 작업을 수행해야합니다 ... 조금 번거롭고 JS 구문이 다른 파일도 발견했습니다. 특히 jsx 파일에서.
eAbi

2
@Sean : 전혀 문제로 보지 않습니다. 데이터 흐름 을 읽는하지, 데이터를 쓰기에 관한 것입니다. 물론 조치가 상점에 대해 무의미한 것이 가장 좋지만 요청을 최적화하기 위해 상점에서 읽는 것이 완벽하다고 생각합니다. 결국 컴포넌트 는 상점에서 읽고 해당 조치를 실행합니다. 모든 구성 요소에서이 논리를 반복 할 수는 있지만 이것이 바로 액션 제작자가 만든 것입니다.
Dan Abramov

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
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.