Мы хотим создать пакет, который позволит нам избавиться от постоянного создания однотипных reducer'ов и action creator'ов для каждой модели, получаемой по API.
Первая часть — вот эта вот статья. В ней мы создали конфиг для нашего будущего пакета и выяснили, что он должен содержать action creator, middleware и reducer. Приступим к разработке!
Action Creator
Начнем мы с самого простого — 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.
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
Напомню, что мы считаем, что к нам приходят сразу нормализованные данные. Тогда в 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 из конфига.
Заключение
В целом, все уже работает, если мы примем такие допущения:
- Можно обойтись без тестирования.
- Никто и никогда не допустит ошибку в конфигах.
- Данные приходят в middleware уже нормализованными.
Все эти пункты (особенно первый!) необходимо тщательно проработать, и мы это сделаем в третьей части. Но это уже не так интересно, ведь почти весь код, выполняющий нашу цель, уже написан, поэтому для тех, кто захочет просто посмотреть и потестить самостоятельно, привожу ссылки: