(Примечание: я использовал синтаксис ES6 с опцией JSX Harmony.)
В качестве упражнения я написал образец приложения Flux, которое позволяет просматривать Github users
и делать репозитории.
Он основан на ответе fisherwebdev, но также отражает подход, который я использую для нормализации ответов API.
Я сделал это, чтобы задокументировать несколько подходов, которые я пробовал при изучении Flux.
Я старался сделать это ближе к реальному миру (разбивка на страницы, никаких поддельных API localStorage).
Здесь есть несколько моментов, которые меня особенно интересовали:
Как я классифицирую магазины
Я попытался избежать дублирования, которое видел в другом примере Flux, особенно в магазинах. Я счел полезным разделить магазины на три категории:
Хранилища содержимого содержат все объекты приложений. Все, что имеет идентификатор, нуждается в собственном Content Store. Компоненты, отображающие отдельные элементы, запрашивают свежие данные в хранилищах содержимого.
Магазины контента собирают свои объекты в результате всех действий сервера. Например, UserStore
проверяет,action.response.entities.users
существует ли он, независимо от того, какое действие было запущено. Нет необходимости в switch
. Normalizr упрощает преобразование любых ответов API в этот формат.
// Content Stores keep their data like this
{
7: {
id: 7,
name: 'Dan'
},
...
}
Хранилища списков отслеживают идентификаторы сущностей, которые появляются в некотором глобальном списке (например, «лента», «ваши уведомления»). В этом проекте у меня таких магазинов нет, но я подумал, что все равно их упомяну. Они обрабатывают нумерацию страниц.
Как правило , они реагируют на всего несколько действий (например 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, ...],
...
}
Примечание: это не настоящие классы или что-то в этом роде; именно так мне нравится думать о магазинах. Но я сделал несколько помощников.
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]));
}
}
}
Сохраняет состояние разбивки на страницы и обеспечивает выполнение определенных утверждений (невозможно получить страницу во время выборки и т. Д.).
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++;
}
}
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
};
Примесь, которая позволяет компонентам настраиваться на интересующие их магазины, например 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;
}
UserListStore
со всеми соответствующими пользователями. И у каждого пользователя будет пара логических флагов, описывающих отношение к текущему профилю пользователя. Что-то вроде{ follower: true, followed: false }
, например. МетодыgetFolloweds()
иgetFollowers()
будут получать различные наборы пользователей, необходимые для пользовательского интерфейса.