Na arquitetura Flux, como você gerencia o ciclo de vida da loja?

132

Estou lendo sobre o Flux, mas o exemplo do aplicativo Todo é muito simplista para eu entender alguns pontos-chave.

Imagine um aplicativo de página única, como o Facebook, que possui páginas de perfil de usuário . Em cada página de perfil do usuário, queremos mostrar algumas informações do usuário e suas últimas postagens, com rolagem infinita. Podemos navegar de um perfil de usuário para outro.

Na arquitetura Flux, como isso corresponderia a lojas e despachantes?

Usaríamos um PostStorepor usuário ou teríamos algum tipo de loja global? E os despachantes, criaríamos um novo Despachante para cada "página de usuário" ou usaríamos um singleton? Por fim, qual parte da arquitetura é responsável por gerenciar o ciclo de vida dos armazenamentos "específicos da página" em resposta à mudança de rota?

Além disso, uma única pseudo-página pode ter várias listas de dados do mesmo tipo. Por exemplo, em uma página de perfil, quero mostrar Seguidores e Segue . Como um singleton pode UserStorefuncionar nesse caso? Iria UserPageStoregerenciar followedBy: UserStoree follows: UserStore?

Dan Abramov
fonte

Respostas:

124

Em um aplicativo Flux, deve haver apenas um Dispatcher. Todos os dados fluem através deste hub central. Ter um despachante único permite gerenciar todas as lojas. Isso se torna importante quando você precisa atualizar a Loja nº 1 e fazer com que a Loja 2 se atualize com base na Ação e no estado da Loja nº 1. O fluxo pressupõe que esta situação é uma eventualidade em uma aplicação grande. Idealmente, essa situação não precisaria acontecer, e os desenvolvedores devem se esforçar para evitar essa complexidade, se possível. Mas o Singleton Dispatcher está pronto para lidar com isso quando chegar a hora.

As lojas também são singletons. Eles devem permanecer o mais independentes e dissociados possível - um universo independente que pode ser consultado a partir de uma visão do controlador. O único caminho para a loja é através do retorno de chamada que ele registra no Dispatcher. A única saída é através das funções getter. As lojas também publicam um evento quando seu estado foi alterado, para que as Views do Controlador possam saber quando consultar o novo estado, usando os getters.

No seu aplicativo de exemplo, haveria um único PostStore. Essa mesma loja pode gerenciar as postagens em uma "página" (pseudo-página) que é mais parecida com o Newsfeed do FB, onde as postagens aparecem de diferentes usuários. Seu domínio lógico é a lista de postagens e pode lidar com qualquer lista de postagens. Quando passamos de pseudo-página para pseudo-página, queremos reinicializar o estado da loja para refletir o novo estado. Também podemos querer armazenar em cache o estado anterior em localStorage como uma otimização para ir e voltar entre pseudo-páginas, mas minha inclinação seria configurar um PageStoreque aguarde todas as outras lojas, gerenciando o relacionamento com localStorage para todas as lojas em a pseudo-página e atualiza seu próprio estado. Observe que isso PageStorenão armazenaria nada sobre as postagens - esse é o domínio doPostStore. Simplesmente saberia se uma pseudo-página específica foi armazenada em cache ou não, porque as pseudo-páginas são seu domínio.

O PostStoreteria um initialize()método. Esse método sempre limpa o estado antigo, mesmo que seja a primeira inicialização, e cria o estado com base nos dados recebidos pela Ação, por meio do Dispatcher. Mover de uma pseudo-página para outra provavelmente envolveria uma PAGE_UPDATEação, o que desencadearia a invocação de initialize(). Há detalhes a serem trabalhados para recuperar dados do cache local, recuperar dados do servidor, renderização otimista e estados de erro XHR, mas essa é a ideia geral.

Se uma pseudo-página específica não precisar de todos os armazenamentos no aplicativo, não tenho muita certeza de que exista algum motivo para destruir os não utilizados, além de restrições de memória. Mas as lojas normalmente não consomem muita memória. Você só precisa remover os ouvintes de eventos nas visualizações de controlador que estão destruindo. Isso é feito no componentWillUnmount()método React .

fisherwebdev
fonte
5
Certamente, existem algumas abordagens diferentes para o que você deseja fazer e acho que depende do que você está tentando criar. Uma abordagem seria a UserListStore, com todos os usuários relevantes nela. E cada usuário teria alguns sinalizadores booleanos descrevendo o relacionamento com o perfil de usuário atual. Algo como { follower: true, followed: false }, por exemplo. Os métodos getFolloweds()e getFollowers()recuperariam os diferentes conjuntos de usuários necessários para a interface do usuário.
Fisherwebdev 11/05
4
Como alternativa, você pode ter um FollowedUserListStore e um FollowerUserListStore que ambos herdam de um UserListStore abstrato.
Fisherwebdev
Tenho uma pequena pergunta - por que não usar pub sub para emitir dados diretamente das lojas, em vez de exigir que os assinantes recuperem os dados?
sunwukung
2
@sunwukung Isso exigiria que as lojas controlassem quais visualizações de controlador precisam de quais dados. É mais fácil fazer com que as lojas publiquem o fato de que elas mudaram de alguma forma e, em seguida, permita que as visualizações de controlador interessadas recuperem quais partes dos dados precisam.
fisherwebdev
E se eu tiver uma página de perfil na qual mostro informações sobre um usuário, mas também uma lista de seus amigos. Tanto o usuário quanto os amigos seriam o mesmo tipo disso. Eles devem permanecer na mesma loja?
Nick Dima
79

(Nota: usei a sintaxe ES6 usando a opção JSX Harmony.)

Como exercício, escrevi um aplicativo Flux de amostra que permite navegar Github userse reposicionar.
É baseado na resposta de fisherwebdev, mas também reflete uma abordagem que eu uso para normalizar as respostas da API.

Consegui documentar algumas abordagens que tentei enquanto aprendia o Flux.
Tentei mantê-lo próximo ao mundo real (paginação, nenhuma API localStorage falsa).

Existem alguns bits aqui nos quais eu estava especialmente interessado:

  • Ele usa arquitetura Flux e reag-router ;
  • Ele pode mostrar a página do usuário com informações conhecidas parciais e detalhes de carregamento em movimento;
  • Ele suporta paginação para usuários e repositórios;
  • Ele analisa as respostas JSON aninhadas do Github com normalizr ;
  • Os armazenamentos de conteúdo não precisam conter um gigante switchcom ações ;
  • "Voltar" é imediato (porque todos os dados estão nas lojas).

Como classifico lojas

Tentei evitar parte da duplicação que já vi em outro exemplo do Flux, especificamente nas lojas. Achei útil dividir logicamente o Stores em três categorias:

Os armazenamentos de conteúdo mantêm todas as entidades de aplicativos. Tudo o que possui um ID precisa de seu próprio armazenamento de conteúdo. Os componentes que processam itens individuais solicitam novos dados aos armazenamentos de conteúdo.

Os armazenamentos de conteúdo coletam seus objetos de todas as ações do servidor. Por exemplo,UserStore olhaaction.response.entities.users , se existir , independentemente de qual a ação disparada. Não há necessidade de a switch. O Normalizr facilita o nivelamento de qualquer resposta da API para esse formato.

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

Os armazenamentos de lista acompanham os IDs de entidades que aparecem em alguma lista global (por exemplo, "feed", "suas notificações"). Neste projeto, eu não tenho essas lojas, mas pensei em mencioná-las de qualquer maneira. Eles lidam com paginação.

Eles normalmente respondem a apenas algumas acções (por exemplo REQUEST_FEED, REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

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

Os Armazéns de listas indexados são como os Armazéns de listas, mas definem o relacionamento um para muitos. Por exemplo, “assinantes de usuários”, “observadores de estrelas do repositório”, “repositórios de usuários”. Eles também lidam com paginação.

Eles também normalmente respondem a apenas algumas acções (por exemplo REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

Na maioria dos aplicativos sociais, você terá muitos deles e deseja criar rapidamente mais um.

// Indexed Paginated Stores keep their data like this
{
  2: [7, 10, 5, ...],
  6: [7, 1, 2, ...],
  ...
}

Nota: estas não são classes reais ou algo assim; é assim que eu gosto de pensar nas lojas. Eu fiz alguns ajudantes embora.

StoreUtils

createStore

Este método fornece a loja mais básica:

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

Eu o uso para criar todas as lojas.

isInBag, mergeIntoBag

Pequenos ajudantes úteis para armazenamentos de conteúdo.

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

Armazena o estado de paginação e aplica certas asserções (não é possível buscar a página durante a busca, etc.).

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

Facilita a criação dos armazenamentos de listas indexadas, fornecendo métodos padronizados e manipulação de ações:

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

Um mixin que permite que os componentes sintonizem-se nas lojas em que estão interessados, por exemplo 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;
}
Dan Abramov
fonte
1
Dado o fato de você ter escrito o Stampsy, se você reescrevesse todo o aplicativo do lado do cliente, usaria o FLUX e a mesma abordagem usada para criar este aplicativo de exemplo?
EABI
2
eAbi: Essa é a abordagem que estamos usando no momento em que reescrevemos o Stampsy no Flux (na esperança de lançá-lo no próximo mês). Não é o ideal, mas funciona bem para nós. Quando / se descobrirmos maneiras melhores de fazer essas coisas, as compartilharemos.
Dan Abramov
1
eAbi: No entanto, não usamos mais o normalizr porque um cara da nossa equipe reescreveu todas as nossas APIs para retornar respostas normalizadas. Foi útil antes que isso fosse feito.
Dan Abramov
Obrigado pela sua informação. Verifiquei o seu repositório do github e estou tentando iniciar um projeto (construído em YUI3) com sua abordagem, mas estou tendo alguns problemas ao compilar o código (se você puder). Como não estou executando o servidor no nó, quis copiar a fonte para o diretório estático, mas ainda tenho que fazer algum trabalho ... É um pouco complicado e também encontrei alguns arquivos com sintaxe JS diferente. Especialmente em arquivos jsx.
EABI
2
@ Sean: Eu não vejo isso como um problema. O fluxo de dados é sobre a gravação de dados, não a leitura. Claro que é melhor que as ações sejam agnósticas em relação às lojas, mas, para otimizar solicitações, acho perfeitamente bom ler nas lojas. Afinal, os componentes leem das lojas e acionam essas ações. Você poderia repetir essa lógica em cada componente, mas isso é o que a ação criadora é para ..
Dan Abramov
27

Portanto, no Reflux, o conceito de Dispatcher é removido e você só precisa pensar em termos de fluxo de dados através de ações e lojas. Ou seja,

Actions <-- Store { <-- Another Store } <-- Components

Cada seta aqui modela como o fluxo de dados é escutado, o que, por sua vez, significa que os dados fluem na direção oposta. A figura real do fluxo de dados é esta:

Actions --> Stores --> Components
   ^          |            |
   +----------+------------+

No seu caso de uso, se eu entendi corretamente, precisamos de uma openUserProfileação que inicie o perfil do usuário carregando e alternando a página e também de algumas postagens carregando ações que carregarão postagens quando a página de perfil do usuário for aberta e durante o evento de rolagem infinita. Então, eu imagino que temos os seguintes armazenamentos de dados no aplicativo:

  • Um armazenamento de dados da página que lida com a alternância de páginas
  • Um armazenamento de dados do perfil do usuário que carrega o perfil do usuário quando a página é aberta
  • Um armazenamento de dados da lista de postagens que carrega e manipula as postagens visíveis

No Reflux, você o configuraria assim:

As ações

// Set up the two actions we need for this use case.
var Actions = Reflux.createActions(['openUserProfile', 'loadUserProfile', 'loadInitialPosts', 'loadMorePosts']);

O armazenamento de páginas

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

O repositório de perfis de usuário

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

A loja de postagens

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

Os componentes

Suponho que você tenha um componente para a exibição de página inteira, a página de perfil de usuário e a lista de postagens. É necessário conectar o seguinte:

  • Os botões que abrem o perfil do usuário precisam chamar o Action.openUserProfile ID correto durante o evento de clique.
  • O componente da página deve estar ouvindo o currentPageStore saber para qual página alternar.
  • O componente da página de perfil do usuário precisa ouvir o currentUserProfileStore saber quais dados do perfil do usuário mostrar
  • A lista de postagens precisa ouvir o currentPostsStore para receber as postagens carregadas
  • O evento de rolagem infinita precisa chamar o Action.loadMorePosts.

E isso deve ser praticamente isso.

Spoike
fonte
Obrigado pela redação!
Dan Abramov 31/07
2
Um pouco atrasado para a festa, talvez, mas aqui está um bom artigo explicando por que evitar chamar você de API diretamente das lojas . Ainda estou descobrindo quais são as melhores práticas, mas achei que poderia ajudar outros a tropeçar nisso. Existem várias abordagens diferentes em relação às lojas.
Thijs Koerselman