تنظيم الوحدات العامة في Vuex

كيف نظمنا متاجر Vuex وتغلبنا على لصق النسخ



Vuex هي مكتبة إدارة حالة التطبيق الرسمية المصممة خصيصًا لإطار عمل Vue.js.



تنفذ Vuex نمط إدارة الحالة الذي يعمل كمخزن بيانات مركزي لجميع مكونات التطبيق.



مع نمو التطبيق ، ينمو هذا التخزين ويتم وضع بيانات التطبيق في كائن كبير واحد.



لحل هذه المشكلة ، يقسم Vuex التخزين إلى وحدات . يمكن أن تحتوي كل وحدة من هذه الوحدات على حالتها الخاصة ، والطفرات ، والإجراءات ، والحاصل ، والوحدات الفرعية المضمنة.



أثناء العمل في مشروع CloudBlue Connect وإنشاء وحدة نمطية أخرى ، وجدنا أنفسنا نعتقد أننا نكتب نفس الكود المعياري مرارًا وتكرارًا ، ونغير فقط نقطة النهاية:



  1. مستودع يحتوي على منطق التفاعل مع الواجهة الخلفية ؛
  2. وحدة Vuex التي تعمل مع المستودع ؛
  3. اختبارات الوحدة للمستودعات والوحدات النمطية.


بالإضافة إلى ذلك ، نعرض البيانات في قائمة واحدة أو طريقة عرض جدول بنفس آليات الفرز والتصفية. ونستخدم نفس منطق استخراج البيانات تقريبًا ، ولكن بنقاط نهاية مختلفة.



. , , .



Vuex, ().



Vuex



1.



BaseRepository REST API. CRUD-, , .



, , API.

, (: /v1/users).



:

query — .



class BaseRepository {
    constructor(entity, version = 'v1') {
        this.entity = entity;
        this.version = version;
    }

    get endpoint() {
        return `/${this.version}/${this.entity}`;
    }

    async query({
        method = 'GET',
        nestedEndpoint = '',
        urlParameters = {},
        queryParameters = {},
        data = undefined,
        headers = {},
    }) {
        const url = parameterize(`${this.endpoint}${nestedEndpoint}`, urlParameters);

        const result = await axios({
            method,
            url,
            headers,
            data,
            params: queryParameters,
        });

        return result;
    }

    ...
}


getTotal — .

Content-Range, : Content-Range: <unit> <range-start>-<range-end>/<size>.



// getContentRangeSize :: String -> Integer
// getContentRangeSize :: "Content-Range: items 0-137/138" -> 138
const getContentRangeSize = header => +/(\w+) (\d+)-(\d+)\/(\d+)/g.exec(header)[4];

...

async getTotal(urlParameters, queryParameters = {}) {
    const { headers } = await this.query({
        queryParameters: { ...queryParameters, limit: 1 },
        urlParameters,
    });

    if (!headers['Content-Range']) {
        throw new Error('Content-Range header is missing');
    }

    return getContentRangeSize(headers['Content-Range']);
}


:



  • listAll — ;
  • list — ( );
  • get — ;
  • create — ;
  • update — ;
  • delete — .


: .



listAll, . getTotal, , . chunkSize .



, .



BaseRepository.js
import axios from 'axios';

// parameterize :: replace substring in string by template
// parameterize :: Object -> String -> String
// parameterize :: {userId: '123'} -> '/users/:userId/activate' -> '/users/123/activate'
const parameterize = (url, urlParameters) => Object.entries(urlParameters)
  .reduce(
    (a, [key, value]) => a.replace(`:${key}`, value), 
    url,
  );

// responsesToCollection :: Array -> Array
// responsesToCollection :: [{data: [1, 2]}, {data: [3, 4]}] -> [1, 2, 3, 4]
const responsesToCollection = responses => responses.reduce((a, v) => a.concat(v.data), []);

// getContentRangeSize :: String -> Integer
// getContentRangeSize :: "Content-Range: items 0-137/138" -> 138
const getContentRangeSize = header => +/(\w+) (\d+)-(\d+)\/(\d+)/g.exec(header)[4];

// getCollectionAndTotal :: Object -> Object
// getCollectionAndTotal :: { data, headers } -> { collection, total }
const getCollectionAndTotal = ({ data, headers }) => ({
  collection: data,
  total: headers['Content-Range'] && getContentRangeSize(headers['Content-Range']),
})

export default class BaseRepository {
  constructor(entity, version = 'v1') {
    this.entity = entity;
    this.version = version;
  }

  get endpoint() {
    return `/${this.version}/${this.entity}`;
  }

  async query({
    method = 'GET',
    nestedEndpoint = '',
    urlParameters = {},
    queryParameters = {},
    data = undefined,
    headers = {},
  }) {
    const url = parameterize(`${this.endpoint}${nestedEndpoint}`, urlParameters);

    const result = await axios({
      method,
      url,
      headers,
      data,
      params: queryParameters,
    });

    return result;
  }

  async getTotal(urlParameters, queryParameters = {}) {
    const { headers } = await this.query({ 
      queryParameters: { ...queryParameters, limit: 1 },
      urlParameters,
    });

    if (!headers['Content-Range']) {
      throw new Error('Content-Range header is missing');
    }

    return getContentRangeSize(headers['Content-Range']);
  }

  async list(queryParameters, urlParameters) {
    const result = await this.query({ urlParameters, queryParameters });

    return { 
      ...getCollectionAndTotal(result),
      params: queryParameters,
    };
  }

  async listAll(queryParameters = {}, urlParameters, chunkSize = 100) {
    const params = { 
      ...queryParameters,
      offset: 0,
      limit: chunkSize,
    };

    const requests = [];
    const total = await this.getTotal(urlParameters, queryParameters);

    while (params.offset < total) {
      requests.push(
        this.query({ 
          urlParameters, 
          queryParameters: params,
        }),
      );

      params.offset += chunkSize;
    }

    const result = await Promise.all(requests);

    return {
      total,
      params: {
        ...queryParameters,
        offset: 0,
        limit: total,
      },
      collection: responsesToCollection(result),
    };
  }

  async create(requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'POST',
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async get(id = '', urlParameters, queryParameters = {}) {
    const { data } = await this.query({
      method: 'GET',
      nestedEndpoint: `/${id}`,
      urlParameters,
      queryParameters,
    });

    return data;
  }

  async update(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'PUT',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }

  async delete(id = '', requestBody, urlParameters) {
    const { data } = await this.query({
      method: 'DELETE',
      nestedEndpoint: `/${id}`,
      urlParameters,
      data: requestBody,
    });

    return data;
  }
}


API, .

, users :



const usersRepository = new BaseRepository('users');
const win0err = await usersRepository.get('USER-007');


, ?

, , POST- /v1/users/:id/activate.

, :



class UsersRepository extends BaseRepository {
    constructor() {
        super('users');
    }

    activate(id) {
        // POST /v1/users/:id/activate
        return this.query({
            nestedEndpoint: '/:id/activate',
            method: 'POST',
            urlParameters: { id },
        });
    }
}


API :



const usersRepository = new UsersRepository();
await usersRepository.activate('USER-007');
await usersRepository.listAll();


2.



, , .

. , , .





, .

value , :



import {
    is,
    clone,
} from 'ramda';

const mutations = {
    replace: (state, { obj, value }) => {
        const data = clone(state[obj]);

        state[obj] = is(Function, value) ? value(data) : value;
    },
}




, - , .

, .



, :



  • collection — ;
  • current — ;
  • total — .




, , : get, list, listAll, create, update delete. , .



, , .





, registerModule: store.registerModule(name, module);.



, , . , , .



StoreFactory.js
import {
  clone,
  is,
  mergeDeepRight,
} from 'ramda';

const keyBy = (pk, collection) => {
  const keyedCollection = {};
  collection.forEach(
      item => keyedCollection[item[pk]] = item,
  );

  return keyedCollection;
}

const replaceState = (state, { obj, value }) => {
  const data = clone(state[obj]);

  state[obj] = is(Function, value) ? value(data) : value;
};

const updateItemInCollection = (id, item) => collection => {
  collection[id] = item;

  return collection
};

const removeItemFromCollection = id => collection => {
  delete collection[id];

  return collection
};

const inc = v => ++v;
const dec = v => --v;

export const createStore = (repository, primaryKey = 'id') => ({
  namespaced: true,

  state: {
    collection: {},
    currentId: '',

    total: 0,
  },

  getters: {
    collection: ({ collection }) => Object.values(collection),
    total: ({ total }) => total,
    current: ({ collection, currentId }) => collection[currentId],
  },

  mutations: {
    replace: replaceState,
  },

  actions: {
    async list({ commit }, attrs = {}) {
      const { queryParameters = {}, urlParameters = {} } = attrs;

      const result = await repository.list(queryParameters, urlParameters);

      commit({
        obj: 'collection',
        type: 'replace',
        value: keyBy(primaryKey, result.collection),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: result.total,
      });

      return result;
    },

    async listAll({ commit }, attrs = {}) {
      const {
        queryParameters = {},
        urlParameters = {},
        chunkSize = 100,
      } = attrs;

      const result = await repository.listAll(queryParameters, urlParameters, chunkSize)

      commit({
        obj: 'collection',
        type: 'replace',
        value: keyBy(primaryKey, result.collection),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: result.total,
      });

      return result;
    },

    async get({ commit, getters }, attrs = {}) {
      const { urlParameters = {}, queryParameters = {} } = attrs;
      const id = urlParameters[primaryKey];

      try {
        const item = await repository.get(
          id,
          urlParameters,
          queryParameters,
        );

        commit({
          obj: 'collection',
          type: 'replace',
          value: updateItemInCollection(id, item),
        });

        commit({
          obj: 'currentId',
          type: 'replace',
          value: id,
        });
      } catch (e) {
        commit({
          obj: 'currentId',
          type: 'replace',
          value: '',
        });

        throw e;
      }

      return getters.current;
    },

    async create({ commit, getters }, attrs = {}) {
      const { data, urlParameters = {} } = attrs;

      const createdItem = await repository.create(data, urlParameters);
      const id = createdItem[primaryKey];

      commit({
        obj: 'collection',
        type: 'replace',
        value: updateItemInCollection(id, createdItem),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: inc,
      });

      commit({
        obj: 'current',
        type: 'replace',
        value: id,
      });

      return getters.current;
    },

    async update({ commit, getters }, attrs = {}) {
      const { data, urlParameters = {} } = attrs;
      const id = urlParameters[primaryKey];

      const item = await repository.update(id, data, urlParameters);

      commit({
        obj: 'collection',
        type: 'replace',
        value: updateItemInCollection(id, item),
      });

      commit({
        obj: 'current',
        type: 'replace',
        value: id,
      });

      return getters.current;
    },

    async delete({ commit }, attrs = {}) {
      const { urlParameters = {}, data } = attrs;
      const id = urlParameters[primaryKey];

      await repository.delete(id, urlParameters, data);

      commit({
        obj: 'collection',
        type: 'replace',
        value: removeItemFromCollection(id),
      });

      commit({
        obj: 'total',
        type: 'replace',
        value: dec,
      });
    },
  },
});

const StoreFactory = (repository, extension = {}) => {
  const genericStore = createStore(
    repository, 
    extension.primaryKey || 'id',
  );

  ['state', 'getters', 'actions', 'mutations'].forEach(
    part => {
      genericStore[part] = mergeDeepRight(
        genericStore[part],
        extension[part] || {},
      );
    }
  )

  return genericStore;
};

export default StoreFactory;




:



const usersRepository = new UsersRepository();
const usersModule = StoreFactory(usersRepository);


, , .

:



import { assoc } from 'ramda';

const usersRepository = new UsersRepository();
const usersModule = StoreFactory(
    usersRepository,
    {
        actions: {
            async activate({ commit }, { urlParameters }) {
                const { id } = urlParameters;
                const item = await usersRepository.activate(id);

                commit({
                    obj: 'collection',
                    type: 'replace',
                    value: assoc(id, item),
                });
            }
        }
    },
);


3.



, , , , :



ResourceFactory.js
import BaseRepository from './BaseRepository';
import StoreFactory from './StoreFactory';

const createRepository = (endpoint, repositoryExtension = {}) => {
  const repository = new BaseRepository(endpoint, 'v1');

  return Object.assign(repository, repositoryExtension);
}

const ResourceFactory = (
  store,
  {
    name,
    endpoint,
    repositoryExtension = {},
    storeExtension = () => ({}),
  },
) => {
    const repository = createRepository(endpoint, repositoryExtension);
    const module = StoreFactory(repository, storeExtension(repository));

    store.registerModule(name, module);
}

export default ResourceFactory;




. , ( ) :



const store = Vuex.Store();

ResourceFactory(
    store,
    {
        name: 'users',
        endpoint: 'users',
        repositoryExtension: {
            activate(id) {
                return this.query({
                    nestedEndpoint: '/:id/activate',
                    method: 'POST',
                    urlParameters: { id },
                });
            },
        },
        storeExtension: (repository) => ({
            actions: {
                async activate({ commit }, { urlParameters }) {
                    const { id } = urlParameters;
                    const item = await repository.activate(id);

                    commit({
                        obj: 'collection',
                        type: 'replace',
                        value: assoc(id, item),
                    });
                }
            }
        }),
    },
);


, : , :



{
    computed: {
        ...mapGetters('users', {
            users: 'collection',
            totalUsers: 'total',
            currentUser: 'current',
        }),

        ...mapGetters('groups', {
            users: 'collection',
        }),

        ...
    },

    methods: {
        ...mapActions('users', {
            getUsers: 'list',
            deleteUser: 'delete',
            updateUser: 'update',
            activateUser: 'activate',
        }),

        ...mapActions('groups', {
            getAllUsers: 'listAll',
        }),

        ...

        async someMethod() {
            await this.activateUser({ urlParameters: { id: 'USER-007' } });
            ...
        }
    },
}


- , .

, , .



:



ResourceFactory(
    store,
    {
        name: 'userOrders',
        endpoint: 'users/:userId/orders',
    },
);


:



{
    ...

    methods: {
        ...mapActions('userOrders', {
            getOrder: 'get',
        }),

        async someMethod() {
            const order = await this.getOrder({ 
                urlParameters: { 
                    userId: 'USER-007',
                    id: 'ORDER-001',
                } 
            });

            console.log(order);
        }
    }
}




. , — . — , . — (mocks), , .

, — , .





, DRY, . , , API . , Content-Range, .



() , , , , -. , , .



, . , .




All Articles