Redux: попытка избавиться от потребности думать во время запросов к API, часть 2
- воскресенье, 11 июня 2017 г. в 03:13:00
Мы хотим создать пакет, который позволит нам избавиться от постоянного создания однотипных reducer'ов и action creator'ов для каждой модели, получаемой по API.
Первая часть — вот эта вот статья. В ней мы создали конфиг для нашего будущего пакета и выяснили, что он должен содержать action creator, middleware и reducer. Приступим к разработке!
Начнем мы с самого простого — action creator'а. Тут наш вклад будет минимальным — нам нужно просто написать традиционный action creator для redux-api-middleware
с учетом нашего конфига.
Для получения юзеров он должен выглядеть приблизительно так:
import {CALL_API} from 'redux-api-middleware';
const get = () => ({
[CALL_API]: {
endpoint: 'mysite.com/api/users',
method: 'GET',
types: ['USERS_GET', 'USERS_SUCCESS', 'USERS_FAILURE']
}
});
В action можно добавить еще headers, credentials. Если запрос успешен, то мы получаем USERS_SUCCESS, и у него в action.payload лежат полученные по API данные. Если произошла ошибка, — получаем USERS_FAILURE, у которого в action.errors лежат ошибки. Все это подробно описано в документации.
В дальнейшем, для простоты рассуждений, будем считать, что данные в payload уже нормализованы. Нас интересует, как можно модернизировать наш creator для получения всех сущностей. Все довольно просто: для того, чтоб возвращать нужные сущности, передаем в creator название этой сущности:
import {CALL_API} from 'redux-api-middleware';
const initGet = (api) => (entity) => ({
[CALL_API]: {
endpoint: api[entity].endpoint, // endpoint мы будем брать из конфига
method: 'GET',
types: api[entity].types // и actions мы будем брать из конфига
}
});
Еще необходимо добавить фильтрацию ответа сервера по GET-параметрам, чтоб мы могли ходить только за нужными данными и не тащить ничего лишнего. Я предпочитаю передавать GET-параметры в качестве словаря и сериализовать их отдельным методом objectToQuery:
import {CALL_API} from 'redux-api-middleware';
const initGet = (api) => (entity, params) => ({
[CALL_API]: {
endpoint: `${api[entity].endpoint}${objectToQuery(params)}`,
method: 'GET',
types: api[entity].types
}
});
Инициализируем сам creator:
const get = initGet(config.api);
Теперь, вызывая с нужными аргументами метод get, мы отправим запрос о необходимых данных. Теперь надо позаботиться о том, как полученные данные хранить — напишем reducer.
Точнее, два. Один будет класть в store сущности, а другой — время их прихода. Хранить их в одном месте — плохая идея, ведь тогда мы будем смешивать чистые данные с локальным состоянием приложения на клиенте (ведь время прихода данных у каждого клиента свое).
Тут нам понадобятся те же successActionTypes и react-addons-update
, который обеспечит иммутабельность store. Тут нам надо будет пройтись по каждой сущности из entities и сделать отдельный $merge, то есть, совместить ключи из defaultStore и receivedData.
const entitiesReducer = (entities = defaultStore, action) => {
if (action.type in successActionTypes) {
const processedData = {};
const receivedData = action.payload.entities || {};
for (let entity in receivedData) {
processedData[entity] = { $merge: receivedData[entity] };
}
return update(entities, processedData);
} else {
return entities;
}
};
Аналогично для timestampReducer, но там мы будем устанавливать текущее время прибытия данных в store:
const now = Date.now();
for (let id in receivedData[entity]) {
entityData[id] = now;
}
processedData[entity] = { $merge: entityData };
schema или lifetime, successActionTypes понадобятся нам при инициализации — аналогичный код мы писали в action creator'е.
Чтоб получить defaultState, сделаем так:
const defaultStore = {};
for (let key in schema) { // или lifetime, для второго reducer'а
defaultStore[key] = {};
}
successActionTypes можно получить из конфига api:
const getSuccessActionTypes = (api) => {
let actionTypes = {};
for (let key in api) {
actionTypes[api[key].types[1]] = key;
}
return actionTypes;
};
Это, конечно, задача простая, но один такой простой reducer сэкономит нам кучу времени на написании своего reducer'а для каждого типа данных.
Рутинная работа закончена — перейдем к основному компоненту нашего пакета, который и будет заботиться о том, чтоб ходить только за теми данными, которые реально нужны, и при этом не заставлять нас думать об этом.
Напомню, что мы считаем, что к нам приходят сразу нормализованные данные. Тогда в middleware мы должны пройтись по всем данным, полученным в entities, и собрать список id отсутствующих связанных сущностей, и сделать запрос к API за этими данными.
const middleware = store => next => action => {
if (action.type in successActionTypes) { // Если это action, в котором пришли данные
const entity = successActionTypes[action.type]; // Определяем тип данных
const receivedEntities = action.payload.entities || {};; // Достаем пришедшие сущности
const absentEntities = resolve(entity, store.getState(), receivedEntities); // Находим отсутствующие
for (let key in absentEntities) {
const ids = absentEntities[key]; // Получаем список id отсутствующих
if (ids instanceof Array && ids.length > 0) { // Если список не пустой
store.dispatch(get(key, {id: ids})); // Отправляем action, который идет за этими и только этими данными
}
}
}
return next(action);
}
successActionTypes, resolve и get нужно передавать middleware при инициализации.
Осталось только реализовать метод resolve, который будет определять, каких данных не хватает. Это, пожалуй, самая интересная и важная часть.
Для простоты мы будем считать, что в store.entities хранятся наши данные. Можно и это вынести как отдельный пункт конфига, и присоединять туда reducer, но на данном этапе это неважно.
Мы должны вернуть abcentEntities — словарь такого вида:
const absentEntities = {users: [1, 2, 3], posts: [107, 6, 54]};
Где в списках хранятся id отсутствующих данных. Чтоб определить, каких данные отсутствуют, нам и пригодятся наши schema и lifetime из конфига.
Вообще, по foreign key может лежать и список id, а не один id — никто не отменял many-to-many и one-to-many relations. Это нам надо будет учесть, проверив тип данных по foreign key, и, если что, сходить за всеми из списка.
const resolve = (type, state, receivedEntities) => {
let absentEntities = {};
for (let key in schema[type]) { // проходим по всем foreign key полученных
const keyType = schema[typeName][key]; // Получаем тип foreign key
absentEntities[keyType] = []; // Инициализируем будущий список отсутствующих
for (let id in receivedEntities[type]) { // Проходим по всем полученным сущностям
// Проверка на список
let keyIdList = receivedEntities[type][id][key];
if (!(keyIdList instanceof Array)) {
keyIdList = [keyIdList];
}
for (let keyId of keyIdList) {
// Проверяем, есть ли id в store
const present = state.entities.hasOwnProperty(keyType) && state.entities[keyType].hasOwnProperty(keyId);
// Проверяем, есть ли он в receivedEntities
const received = receivedEntities.hasOwnProperty(keyType) && receivedEntities[keyType].hasOwnProperty(keyId);
// Проверяем, не просрочены ли данные?
const relevant = present && !!lifetime ? state.timestamp[keyType][keyId] + lifetime[keyType] > Date.now() : true;
// Если он получен в данном action, или лежит в store и актуален, класть его в absent нет смысла
if (!(received || (present && relevant))) {
absentEntities[keyType].push(keyId);
}
}
}
};
Вот и все — немного головоломной логики и рассмотрения всех случаев, и наша функция готова. При инициализации надо не забыть передать в нее schema и lifetime из конфига.
В целом, все уже работает, если мы примем такие допущения:
Все эти пункты (особенно первый!) необходимо тщательно проработать, и мы это сделаем в третьей части. Но это уже не так интересно, ведь почти весь код, выполняющий нашу цель, уже написан, поэтому для тех, кто захочет просто посмотреть и потестить самостоятельно, привожу ссылки: