javascript

Redux: попытка избавиться от потребности думать во время запросов к API, часть 2

  • воскресенье, 11 июня 2017 г. в 03:13:00
https://habrahabr.ru/post/330634/
  • Разработка веб-сайтов
  • ReactJS
  • JavaScript
  • API


Мы хотим создать пакет, который позволит нам избавиться от постоянного создания однотипных 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 из конфига.


Заключение


В целом, все уже работает, если мы примем такие допущения:


  1. Можно обойтись без тестирования.
  2. Никто и никогда не допустит ошибку в конфигах.
  3. Данные приходят в middleware уже нормализованными.

Все эти пункты (особенно первый!) необходимо тщательно проработать, и мы это сделаем в третьей части. Но это уже не так интересно, ведь почти весь код, выполняющий нашу цель, уже написан, поэтому для тех, кто захочет просто посмотреть и потестить самостоятельно, привожу ссылки: