[Из песочницы] Vuex нарушает инкапсуляцию

Когда мой проект на Vue начал разрастаться и достиг нескольких сотен компонентов, я задумался о подходе Vue и Vuex к архитектуре проекта.


8qekda6erca1yojq9p6d7qnjjzu.png

Я начал использовать Vue в своих проектах около 3 лет назад. Тогда я использовал чистый js для написания кода и считал, что место js только во frontend'е, а NodeJs не приспособлен к большим проектам, но я ошибался...

Самым значимым событием для изменения мнения стало знакомство с Typescript.
Типизация помогает не только лучше документировать свой код, но и отлавливать большинство ошибок во время компиляции при рефакторинге кода или добавлении нового функционала. Так же typescript позволяет использовать все преимущества ООП.

После того, как я перешел от js к typescript я начал глубже изучать ООП, паттерны проектирования и архитектурные шаблоны. В то же время я познакомился с книгами Роберта Мартина, которые помогли мне структурировать информацию и проанализировать практики, которые я применял, в особенности архитектуру проектов на Vue.

Архитектурный подход Vuex:


fupn0eoshovoxxz1csrbzl67hoy.png

На изображении выше, на сайте Vuex, а так же в разных туториалах пишут, что vuex отвечает за работу с апи и состоянием, что является нарушением принципа единственной ответственности (SRP). Обращаясь к официальной документации Vuex является "централизованным хранилищем данных". Это означает, что в нем должна быть логика изменения состояния, но никак не логика запросов к апи. Тогда для устранения нарушения SRP мы должны вынести работу с апи в отдельный класс. Где же инициализировать этот класс?

В своих проектах я создаю три класса (один для общения с апи, второй для общения с локальным хранилищем, третий — репозиторий, который вызывается бизнес-логикой и возвращает данные либо из апи, либо из локального хранилища). Таким образом мне необходимо инициализировать три объекта. Для решения этой задачи я использую DI контейнер. Для того, чтобы DI не превратился в Service Locator необходимо явно указывать от чего зависит тот или иной класс.
Из всего этого следует, что для создания контейнера Vuex нам необходима фабричная функция, которая бы принимала в качестве аргументов зависимости данного контейнера Vuex.

Создадим произвольный контейнер Vuex с использованием vuex-smart-module для статической типизации:

class UserState {
    firstName: string = '';
    lastName: string = '';
}

class UserGetters extends Getters<UserState> {
    get fullName() {
        return this.state.firstName + ' ' + this.state.lastName;
    }
}

class UserMutations extends Mutations<UserState> {
    setFirstName(firstName: string) {
        this.state.firstName = firstName;
    }

    setLastName(lastName: string) {
        this.state.lastName = lastName;
    }
}

class UserActions extends Actions<
    UserState,
    UserGetters,
    UserMutations,
    UserActions
    > {
    async load() {
        return new Promise((resolve) => {
            setTimeout(() => {
                resolve();
                this.mutations.setFirstName('FirstName');
                this.mutations.setLastName('LastName');
            }, 2000);
        });
    }
}

export const userStore = new Module({
    namespaced: true,
    state: UserState,
    getters: UserGetters,
    mutations: UserMutations,
    actions: UserActions
})

А так же класс User и ApiDatasource (который в данном случае является заглушкой):

class User {
    id: number;
    firstName: string;
    lastnName: string;

    constructor(props: {
        id: number;
        firstName: string;
        lastnName: string;
    }) {
        this.id = props.id;
        this.firstName = props.firstName;
        this.lastnName = props.lastnName;
    }
}

class ApiDatasource {
    async getCurrentUser(): Promise<User> {
        return new User({
            id: 0,
            firstName: 'Name',
            lastnName: 'Lear',
        });
    }
}

Куда в данном случае мы можем положить объект ApiDatasource (при условии, что Vuex модуль создается при помощи new Module)? Я вижу только один выход, вставить этот объект в State (если есть другие варианты, предложите в комментарии).

Модифицированные UserState и UserActions:

class UserState {
    firstName: string = '';
    lastName: string = '';
    apiDatasource: ApiDatasource | null = null;
}
class UserActions extends Actions<
    UserState,
    UserGetters,
    UserMutations,
    UserActions
    > {
    async load() {
        if (this.state.apiDatasource !== null) {
            let currUser = await this.state.apiDatasource.getCurrentUser();
            this.mutations.setFirstName(currUser.firstName);
            this.mutations.setLastName(currUser.lastName);
        }
    }
}

А так же функция регистрации модуля:

const UserStoreModuleName = 'user;'
export function registerUserStore(props: {
    apiDatasource: ApiDatasource;
    vuexStore: Store<any>;
}) {
    registerModule(props.vuexStore, [UserStoreModuleName], UserStoreModuleName, userStore);
    (props.vuexStore.state[UserStoreModuleName] as UserState).apiDatasource = props.apiDatasource;
}

(Да, это напоминает больше костыль, чем решение)
Тут мы сталкиваемся с первой проблемой, а именно props.vuexStore.state[UserStoreModuleName] не типизирован. Внутри пакета мы это решаем при помощи приведения к типу (props.vuexStore.state[UserStoreModuleName] as UserState). Но ведь мы будем использовать state внутри экземпляра vue, следовательно, нам везде придется использовать приведение к типу.

На этом проблемы с типизацией не заканчиваются. Как нам вызывать изменение состояния? Согласно официальной документации, нам необходимо вызывать либо функцию commit, либо dispatch с указанием имени метода и модуля.
Например, вот так:

store.dispatch('user/load')

Тут кроется не только проблема типизации (мы не знаем что принимает и возвращает определенный action или mutation), но и то, что во время компиляции мы не знаем есть ли такой модуль или метод. Обнаружится проблема только во время рантайма.

Такое поведение напоминает Service Locator. Во время компиляции экземпляр Vue не знает, ни какой модуль vuex, ни какой именно action или mutation используется. Допустим, наш проект разросся и мы отказались от action load в модуле user, но компилятор нам не скажет об ошибке (мы вызываем метод, которого теперь не существует), ведь код останется валидным, но будет нерабочим. Тогда для рефакторинга нам потребуется полный поиск в проекте по имени метода load. Если бы не было нарушения инкапусляции, то компилятор смог бы нас оповестить обо всех местах, где требуются изменения, а в IDE мы бы могли списком просмотреть все места, где код стал нерабочим.

Одно из главных преимуществ инкапсуляции это абстрагирование. Vuex абстрагирует работу с состоянием и позволяет нам не зависеть от реализации того или иного action. Но какой ценой? Мы не знаем ни пред, ни пост-условий не то, чтобы отдельного action, а в целом у всего модуля vuex.

В итоге получается, что vuex берет слишком много ответственности (работа с апи и контроль состояния) нарушая SRP, а так же нарушает инкапсуляцию. Да, вы можете вынести работу с апи в отдельный класс, но как передать данный объект для работы с апи во vuex без костылей? А причина, по которой vuex нарушает инкапсуляцию довольно проста — vuex скрывает пред и пост-условия для его использования в экземплярах Vue.

© Habrahabr.ru