Dalam arsitektur Flux, bagaimana Anda mengelola siklus hidup Store?

132

Saya membaca tentang Flux tetapi contoh aplikasi Todo terlalu sederhana bagi saya untuk memahami beberapa poin utama.

Bayangkan aplikasi satu halaman seperti Facebook yang memiliki halaman profil pengguna . Pada setiap halaman profil pengguna, kami ingin menampilkan beberapa informasi pengguna dan posting terakhir mereka, dengan gulir yang tak terbatas. Kami dapat menavigasi dari satu profil pengguna ke profil lainnya.

Dalam arsitektur Flux, bagaimana ini sesuai dengan Toko dan Dispatcher?

Apakah kita akan menggunakan satu PostStoreper pengguna, atau apakah kita memiliki semacam toko global? Bagaimana dengan dispatcher, apakah kita akan membuat Dispatcher baru untuk setiap "halaman pengguna", atau apakah kita akan menggunakan singleton? Akhirnya, bagian arsitektur apa yang bertanggung jawab untuk mengelola siklus hidup Toko “khusus halaman” sebagai respons terhadap perubahan rute?

Selain itu, satu halaman semu mungkin memiliki beberapa daftar data dengan tipe yang sama. Misalnya, di halaman profil, saya ingin menunjukkan Pengikut dan Pengikut . Bagaimana cara UserStorekerja tunggal dalam kasus ini? Akankah UserPageStoremengelola followedBy: UserStoredan follows: UserStore?

Dan Abramov
sumber

Jawaban:

124

Dalam aplikasi Flux hanya ada satu Dispatcher. Semua data mengalir melalui hub pusat ini. Memiliki Dispatcher tunggal memungkinkannya untuk mengelola semua Toko. Ini menjadi penting ketika Anda membutuhkan pembaruan Store # 1 sendiri, dan kemudian perbarui Store # 2 itu sendiri berdasarkan Aksi dan pada kondisi Store # 1. Flux mengasumsikan situasi ini adalah kemungkinan dalam aplikasi besar. Idealnya situasi ini tidak perlu terjadi, dan pengembang harus berusaha untuk menghindari kompleksitas ini, jika memungkinkan. Tapi Dispatcher singleton siap menanganinya ketika saatnya tiba.

Toko juga lajang. Mereka harus tetap independen dan terpisah sebanyak mungkin - alam semesta mandiri yang bisa di-query dari Controller-View. Satu-satunya jalan menuju Store adalah melalui panggilan balik yang terdaftar pada Dispatcher. Satu-satunya jalan keluar adalah melalui fungsi pengambil. Toko juga menerbitkan acara ketika keadaannya telah berubah, sehingga Pengendali-Tampilan dapat mengetahui kapan harus mengajukan permintaan untuk keadaan baru, menggunakan getter.

Di contoh aplikasi Anda, akan ada satu PostStore. Toko yang sama ini dapat mengelola posting di "halaman" (halaman semu) yang lebih mirip dengan Umpan Berita FB, tempat posting muncul dari pengguna yang berbeda. Domain logisnya adalah daftar posting, dan dapat menangani semua daftar posting. Ketika kami pindah dari halaman semu ke halaman semu, kami ingin menginisialisasi ulang keadaan toko untuk mencerminkan keadaan baru. Kami mungkin juga ingin men-cache keadaan sebelumnya di localStorage sebagai optimisasi untuk bergerak bolak-balik antara halaman semu, tetapi kecenderungan saya adalah untuk mengatur PageStoreyang menunggu semua toko lain, mengelola hubungan dengan localStorage untuk semua toko di halaman semu, dan kemudian memperbarui kondisinya sendiri. Perhatikan bahwa ini tidak PageStoreakan menyimpan apa pun tentang pos - itulah domain dariPostStore. Itu hanya akan tahu apakah halaman pseudo tertentu telah di-cache atau tidak, karena halaman pseudo adalah domainnya.

The PostStoreakan memiliki initialize()metode. Metode ini akan selalu menghapus status lama, bahkan jika ini adalah inisialisasi pertama, dan kemudian membuat status berdasarkan data yang diterima melalui Aksi, melalui Dispatcher. Pindah dari satu halaman semu ke halaman lain mungkin akan melibatkan PAGE_UPDATEtindakan, yang akan memicu permohonan initialize(). Ada detail untuk dikerjakan di sekitar pengambilan data dari cache lokal, pengambilan data dari server, rendering optimistis dan status kesalahan XHR, tapi ini adalah ide umum.

Jika halaman pseudo tertentu tidak memerlukan semua Toko dalam aplikasi, saya tidak sepenuhnya yakin ada alasan untuk menghancurkan yang tidak digunakan, selain kendala memori. Tetapi toko biasanya tidak menggunakan banyak memori. Anda hanya perlu memastikan untuk menghapus acara pendengar di Controller-Views yang Anda hancurkan. Ini dilakukan dalam componentWillUnmount()metode Bereaksi .

fisherwebdev
sumber
5
Tentu ada beberapa pendekatan berbeda untuk apa yang ingin Anda lakukan, dan saya pikir itu tergantung pada apa yang Anda coba bangun. Satu pendekatan akan menjadi UserListStore, dengan semua pengguna yang relevan di dalamnya. Dan setiap pengguna akan memiliki beberapa bendera boolean yang menggambarkan hubungan dengan profil pengguna saat ini. Sesuatu seperti { follower: true, followed: false }, misalnya. Metode getFolloweds()dan getFollowers()akan mengambil set pengguna yang berbeda yang Anda butuhkan untuk UI.
fisherwebdev
4
Atau, Anda bisa memiliki FollowedUserListStore dan FollowerUserListStore yang keduanya diwarisi dari UserListStore abstrak.
fisherwebdev
Saya punya pertanyaan kecil - mengapa tidak menggunakan sub pub untuk memancarkan data dari toko secara langsung daripada meminta pelanggan untuk mengambil data?
sunwukung
2
@sunwukung Ini akan mengharuskan toko untuk melacak apa yang dilihat pengontrol membutuhkan data apa. Lebih bersih untuk memiliki toko mempublikasikan fakta bahwa mereka telah berubah dalam beberapa cara, dan kemudian membiarkan pandangan pengontrol yang tertarik mengambil bagian mana dari data yang mereka butuhkan.
fisherwebdev
Bagaimana jika saya memiliki halaman profil tempat saya menampilkan info tentang seorang pengguna tetapi juga daftar teman-temannya. Baik pengguna dan teman akan menjadi tipe yang sama. Haruskah mereka tinggal di toko yang sama jika demikian?
Nick Dima
79

(Catatan: Saya telah menggunakan sintaks ES6 menggunakan opsi JSX Harmony.)

Sebagai latihan, saya menulis contoh aplikasi Flux yang memungkinkan untuk menjelajah Github usersdan repo.
Ini didasarkan pada jawaban fisherwebdev tetapi juga mencerminkan pendekatan yang saya gunakan untuk menormalkan respons API.

Saya berhasil mendokumentasikan beberapa pendekatan yang telah saya coba saat mempelajari Flux.
Saya mencoba untuk tetap dekat dengan dunia nyata (pagination, tidak ada API localStorage palsu).

Ada beberapa bit di sini saya sangat tertarik pada:

Bagaimana Saya Mengklasifikasikan Toko

Saya mencoba menghindari beberapa duplikasi yang saya lihat di contoh Flux lain, khususnya di Toko. Saya merasa berguna untuk membagi Toko secara logis ke dalam tiga kategori:

Toko Konten menampung semua entitas aplikasi. Segala sesuatu yang memiliki ID membutuhkan Content Store-nya sendiri. Komponen yang membuat item individual meminta Content Store untuk data baru.

Konten Toko memanen objek mereka dari semua tindakan server. Misalnya, UserStore perhatikanaction.response.entities.users apakah ada tindakan apa pun yang diluncurkan. Tidak perlu untuk switch. Normalizr memudahkan untuk meratakan setiap respons API ke format ini.

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

Daftar Toko melacak ID entitas yang muncul di beberapa daftar global (misalnya "umpan", "pemberitahuan Anda"). Dalam proyek ini, saya tidak memiliki Toko seperti itu, tetapi saya pikir saya akan tetap menyebutkannya. Mereka menangani pagination.

Mereka biasanya menanggapi hanya beberapa tindakan (misalnya REQUEST_FEED, REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

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

Daftar Toko Terindeks seperti Daftar Toko tetapi mereka mendefinisikan hubungan satu-ke-banyak. Misalnya, "pelanggan pengguna", "stargazer repositori", "repositori pengguna". Mereka juga menangani pagination.

Mereka juga biasanya menanggapi hanya beberapa tindakan (misalnya REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

Di sebagian besar aplikasi sosial, Anda akan memiliki banyak ini dan Anda ingin dapat membuatnya dengan cepat.

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

Catatan: ini bukan kelas aktual atau sesuatu; itu hanya bagaimana saya suka berpikir tentang Toko. Saya membuat beberapa pembantu.

StoreUtils

createStore

Metode ini memberi Anda Toko paling dasar:

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

Saya menggunakannya untuk membuat semua Toko.

isInBag, mergeIntoBag

Pembantu kecil berguna untuk Toko Konten.

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

Menyimpan status pagination dan memberlakukan pernyataan tertentu (tidak dapat mengambil halaman saat mengambil, dll).

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

Jadikan pembuatan Toko Daftar Terindeks sesederhana mungkin dengan menyediakan metode boilerplate dan penanganan tindakan:

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 yang memungkinkan komponen untuk mendengarkan Toko yang mereka minati, misalnya 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
sumber
1
Mengingat fakta bahwa Anda telah menulis Stampsy, jika Anda akan menulis ulang seluruh aplikasi sisi klien, apakah Anda akan menggunakan FLUX dan pendekatan yang sama yang Anda gunakan untuk membuat contoh aplikasi ini?
eAbi
2
eAbi: Ini adalah pendekatan yang saat ini kami gunakan karena kami sedang menulis ulang Stampsy di Flux (berharap untuk merilisnya bulan depan). Itu tidak ideal tetapi itu bekerja dengan baik untuk kita. Ketika / jika kita menemukan cara yang lebih baik untuk melakukan hal-hal itu, kita akan membagikannya.
Dan Abramov
1
eAbi: Namun kami tidak menggunakan normalizr lagi karena seorang lelaki satu tim kami menulis ulang semua API kami untuk mengembalikan respons yang dinormalisasi. Itu berguna sebelum itu dilakukan sekalipun.
Dan Abramov
Terima kasih atas informasi anda. Saya telah memeriksa repo github Anda dan saya mencoba memulai proyek (dibangun di YUI3) dengan pendekatan Anda, tetapi saya mengalami beberapa masalah saat menyusun kode (jika Anda dapat mengatakannya). Saya tidak menjalankan server di bawah simpul jadi saya ingin menyalin sumber ke direktori statis saya tetapi saya masih harus melakukan beberapa pekerjaan ... Ini agak rumit, dan juga, saya menemukan beberapa file memiliki sintaks JS yang berbeda. Terutama di file JSX.
eAbi
2
@Sean: Saya tidak melihatnya sebagai masalah sama sekali. The aliran data adalah tentang menulis data, tidak membacanya. Tentu lebih baik jika tindakan agnostik dari toko, tetapi untuk mengoptimalkan permintaan saya pikir itu baik-baik saja untuk membaca dari toko Setelah semua, komponen membaca dari toko dan memecat tindakan itu. Anda dapat mengulangi logika ini di setiap komponen, tetapi untuk itulah pembuat tindakan ..
Dan Abramov
27

Jadi dalam Reflux konsep Dispatcher dihapus dan Anda hanya perlu berpikir dalam hal aliran data melalui tindakan dan toko. Yaitu

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

Setiap panah di sini memodelkan bagaimana aliran data didengarkan, yang pada gilirannya berarti bahwa data mengalir dalam arah yang berlawanan. Angka aktual untuk aliran data adalah ini:

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

Dalam kasus penggunaan Anda, jika saya mengerti dengan benar, kami memerlukan openUserProfiletindakan yang memulai pemuatan profil pengguna dan mengalihkan halaman dan juga beberapa tindakan pemuatan posting yang akan memuat posting ketika halaman profil pengguna dibuka dan selama acara gulir yang tak terbatas. Jadi saya bayangkan kita memiliki penyimpanan data berikut dalam aplikasi:

  • Menyimpan data halaman yang menangani berpindah halaman
  • Menyimpan data profil pengguna yang memuat profil pengguna saat halaman dibuka
  • Data daftar posting menyimpan yang memuat dan menangani posting yang terlihat

Di Reflux Anda akan mengaturnya seperti ini:

Tindakannya

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

Toko halaman

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

Toko profil pengguna

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

Toko posting

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

Komponen-komponennya

Saya berasumsi Anda memiliki komponen untuk seluruh tampilan halaman, halaman profil pengguna dan daftar posting. Yang berikut perlu disambungkan:

  • Tombol-tombol yang membuka profil pengguna harus memohon Action.openUserProfiledengan id yang benar selama acara klik itu.
  • Komponen halaman harus mendengarkan currentPageStoresehingga ia tahu halaman yang akan dituju.
  • Komponen halaman profil pengguna perlu mendengarkan currentUserProfileStoresehingga ia tahu apa yang ditampilkan data profil pengguna
  • Daftar posting perlu mendengarkan currentPostsStoreuntuk menerima posting yang dimuat
  • Acara gulir tak terbatas perlu memanggil Action.loadMorePosts.

Dan itu sudah cukup.

Spoike
sumber
Terima kasih untuk langganannya!
Dan Abramov
2
Agak terlambat ke pesta mungkin, tapi di sini ada artikel yang bagus menjelaskan mengapa untuk menghindari memanggil Anda API langsung dari toko . Saya masih mencari tahu apa praktik terbaiknya, tetapi saya pikir itu mungkin membantu orang lain untuk tersandung. Ada banyak pendekatan berbeda untuk toko.
Thijs Koerselman