Vuex нарушает инкапсуляцию

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



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


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


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


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



На изображении выше, на сайте 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.

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

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

    0

    АХАХАХАХ. Но да. Я просто смирился и делаю обработку и сохранения состояния в vuex. Лучше к сожалению не сделать. Что еще больше меня удручает, в документации про общую архитектуру и что делать при проблемах роста молчок. Нет лучших практик от слова совсем.

      0

      Вообщем не должно возникать проблем при использовании vuex только в качестве хранилища. Но проблемы начинаются, если пытаешься делать что-либо помимо хранения данных.
      Хотя у меня и с хранением иногда возникают проблемы, приходится решать хуками vuex, что называть кроме как костылем не повернётся язык (проблема была связана с тем, что vuex не обновлял computed реактивно)

        0

        Неее. Я про концепцию саму. Там в первичной концепции дыра.


        • commit — фактически тут и работаем с данными. Данные пришли делаем трансформацию и прочее.
        • dispatch — асинхронный и тут у нас врывается работа с получением данных и передачей параметров

        Оно первоначально так сделано что так и больше никак. Причем в большем числе случаев проще получается всю обработку положить в dispatch, а в commit делать тупо сейв в хранилище.


        Хотя по идее в dispatch мы должны брать входящие данные перекладывать их http далее обрабатывать тут ошибки возникшие и положить raw данные в commit, а там уже обработать.


        А если идти еще дальше, то вызовы в http из dispatch надо выносить в отдельный слой который прячет реализацию вызовов.


        Но все это дружно порождает такое лютое количество boilerplate кода, что народ срезает углы. Я в том числе. В итоге основной код лежит в dispatch. А commit используется только как прокладка между store и dispatch. Потому что убрать никак. Те же getters тоже используются только в случае если одинаковых вызовов больше двух. Проблема все та же boilerplate.

          0

          Если про это, то да. Почти всегда использую commit'ы в качестве прокси для изменения state

      0
      MobX так же прекрасно работает с Vue, можно полностью заменить и глобальное состояние и локальное у компонентов.
        0
        Vue даже не проверяет пропсы на моменте компиляции. Можно создать экземпляр с обязательным пропсом, но не передавать этот пропс при использовании экземпляра. Vue ругнется только в рантайме.

        Я с MobX не работал. Как там обстоят дела с модулями? Если там есть модули:
        1. Можно ли их подгружать в момент работы?
        2. Как происходит работа с модулями (получение состояния, изменение состояния)

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

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