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

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

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


    Комментарии 4

      0
      Что думаете по поводу Apollo?
        0
        За GraphQL вообще следить интересно, но никак не могу найти время, чтоб полноценно разобраться и написать с его помощью что-то свое.
        +1
        Это уже весь фронтэнд поражен псевдопрограммированием или еще не весь? :)
          0

          Посмотрите в эту сторону https://github.com/redux-effects/redux-effects

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое