Как организовать ваши зависимости во Vue-приложении

    Все, кто знаком с Vue, знают, что у Vue-приложения одна точка входа — файл main.js. Там, помимо создания экземпляра Vue, происходит импорт и своего рода Dependency Injection всех ваших глобальных зависимостей (директив, компонентов, плагинов). Чем больше проект, тем больше становится зависимостей, которые, к тому же, имеют каждая свою конфигурацию. В итоге получим один огромный файл со всеми конфигурациями.
    В этой статье речь пойдет о том, как организовать глобальные зависимости, чтобы этого избежать.



    Для чего писать это самим?


    Многие могут подумать – зачем это нужно, если есть, например, Nuxt, который это сделает за вас? В своих проектах я использовал его тоже, однако в простых проектах это может оказаться избыточным. Кроме того, никто не отменял проекты с legacy-кодом, которые падают на вас, как снег на голову. И подключать туда фреймворк – практически делать его с нуля.

    Идейный вдохновитель


    Вдохновителем такой организации явился Nuxt. Он был использован мной на крупном проекте с Vue.
    У Nuxt есть прекрасная фича – plugins. Каждый плагин – это файл, который экспортирует функцию. В функцию передается конфиг, который также будет передан конструктору Vue при создании экземпляра, а также весь store.

    Кроме того, в каждом плагине доступна крайне полезная функция – inject. Она делает Dependency Injection в корневой экземпляр Vue и в объект store. А это значит, что в каждом компоненте, в каждой функции хранилища указанная зависимость будет доступна через this.

    Где это может пригодиться?


    Помимо того, что main.js существенно «похудеет», вы также получите возможность использования зависимости в любом месте приложения без лишних импортов.

    Яркий пример Dependency Injection – это vue-router. Он используется не так уж и часто – получить параметры текущего роута, сделать редирект, однако это глобальная зависимость. Если он может пригодиться в любом компоненте, то почему бы не сделать его глобальным? К тому же, благодаря этому его состояние тоже будет храниться глобально и меняться для всего приложения.

    Другой пример – vue-wait. Разработчики этого плагина пошли дальше и добавили свойство $wait не только в экземпляр Vue, но и во vuex store. Учитывая специфику плагина, это оказывается крайне полезным. Например, в store есть action, который вызывается в нескольких компонентах. И в каждом случае нужно показать лоадер на каком-то элементе. Вместо того, чтобы до и после каждого вызова action вызывать $wait.start('action') и $wait.end('action'), можно просто вызвать эти методы один раз в самом action. И это гораздо более читаемо и менее многословно, чем dispatch('wait/start', 'action' {root: true}). В случае со store это синтаксический сахар.

    От слов к коду


    Базовая структура проекта


    Посмотрим, как сейчас выглядит проект:
    src
    - store
    - App.vue
    - main.js

    main.js выглядит примерно так:
    import Vue from 'vue';
    import App from './App.vue';
    import store from './store';
    
    new Vue({
      render: h => h(App),
      store
    }).$mount('#app');
    


    Подключаем первую зависимость


    Теперь мы хотим подключить в наш проект axios и создать для него некую конфигурацию. Я придерживался терминологии Nuxt и создал в src каталог plugins. Внутри каталога – файлы index.js и axios.js.

    src
    - plugins
    -- index.js
    -- axios.js
    - store
    - App.vue
    - main.js

    Как было сказано выше, каждый плагин должен экспортировать функцию. При этом внутри функции мы хотим иметь доступ к store и впоследствии – функцию inject.

    axios.js
    import axios from 'axios';
    
    export default function (app) {
      // можем задать здесь любую конфигурацию плагина – заголовки, авторизацию, interceptors и т.п.
      axios.defaults.baseURL = process.env.API_BASE_URL;
      axios.defaults.headers.common['Accept'] = 'application/json';
      axios.defaults.headers.post['Content-Type'] = 'application/json';
    
      axios.interceptors.request.use(config => {
        ...
        return config;
      });
    }
    

    index.js:
    import Vue from 'vue';
    import axios from './axios';
    
    export default function (app) {
      let inject = () => {}; // объявляем функцию inject, позже мы добавим в нее код для Dependency Injection
      axios(app, inject); // передаем в наш плагин будущий экземпляр Vue и созданную функцию
    }
    


    Как можно заметить, файл index.js тоже экспортирует функцию. Это сделано для того, чтобы иметь возможность передать туда объект app. Теперь немного поменяем main.js и вызовем эту функцию.

    main.js:
    import Vue from 'vue';
    import App from './App.vue';
    import store from './store';
    import initPlugins from './plugins'; // импортируем новую функцию
    
    // объект, который передается конструктору Vue, объявляем отдельно, чтобы передать его функции initPlugins
    const app = {
      render: h => h(App),
      store
    };
    
    initPlugins(app); 
    
    new Vue(app).$mount('#app'); // измененный функцией initPlugins объект передаем конструктору
    


    Результат


    На данном этапе мы добились того, что убрали конфигурацию плагина из main.js в отдельный файл.

    Кстати, польза от передачи объекта app всем нашим плагинам в том, что внутри каждого плагина у нас теперь есть доступ к store. Можно свободно использовать его, вызывая commit, dispatch, а также обращаясь к store.state и store.getters.

    Если вы любите ES6-style, можете даже сделать так:

    axios.js
    import axios from 'axios';
    
    export default function ({store: {dispatch, commit, state, getters}}) {
      ...
    }
    

    Второй этап – Dependency Injection


    Мы уже создали первый плагин и сейчас наш проект выглядит так:

    src
    - plugins
    -- index.js
    -- axios.js
    - store
    - App.vue
    - main.js

    Так как в большинстве библиотек, где это действительно необходимо, Dependency Injection уже реализована за счет Vue.use, то мы создадим свой собственный простой плагин.

    Например, попробуем повторить то, что делает vue-wait. Это достаточно тяжелая библиотека, поэтому если вы хотите показать лоадер на паре кнопок, лучше от нее отказаться. Однако я не смог устоять перед ее удобством и повторил в своем проекте ее базовый функционал, включая синтаксический сахар в store.

    Wait Plugin


    Создадим в каталоге plugins еще один файл – wait.js.

    У меня уже есть vuex-модуль, который я также назвал wait. Он делает три простых действия:

    start — устанавливает в state свойство объекта с именем action в true
    end — удаляет из state свойство объекта с именем action
    is — получает из state свойство объекта с именем action

    В этом плагине мы будем его использовать.

    wait.js
    export default function ({store: {dispatch, getters}}, inject) {
      const wait = {
        start: action => dispatch('wait/start', action),
        end: action => dispatch('wait/end', action),
        is: action => getters['wait/waiting'](action)
      };
    
      inject('wait', wait);
    }
    


    И подключаем наш плагин:

    index.js:
    import Vue from 'vue';
    import axios from './axios';
    import wait from './wait';
    
    export default function (app) {
      let inject = () => {};  Injection
      axios(app, inject);
      wait(app, inject);
    }
    


    Функция inject


    Теперь реализуем функцию inject.
    // функция принимает 2 параметра:
    // name – имя, по которому плагин будет доступен в this. Обратите внимание, что во Vue принято использовать имя с префиксом доллар для Dependency Injection
    // plugin – непосредственно, что будет доступно по имени в this. Как правило, это объект, но может быть также любой другой тип данных или функция
    let inject = (name, plugin) => {
        let key = `$${name}`; // добавляем доллар к имени свойства
        app[key] = plugin; // кладем свойство в объект app
        app.store[key] = plugin; // кладем свойство в объект store
    
       // магия Vue.prototype
        Vue.use(() => {
          if (Vue.prototype.hasOwnProperty(key)) {
            return;
          }
          Object.defineProperty(Vue.prototype, key, {
            get () {
              return this.$root.$options[key];
            }
          });
        });
      };
    


    Магия Vue.prototype


    Теперь о магии. В документации Vue сказано, что достаточно написать Vue.prototype.$appName = 'Моё приложение'; и $appName станет доступно в this.

    Однако на деле оказалось, что это не так. Вследствие гуглинга не нашлось ответа, почему такая конструкция не заработала. Поэтому я решил обратиться к авторам плагина, которые уже это реализовали.

    Глобальный mixin


    Как и в нашем примере, я посмотрел код плагина vue-wait. Они предлагают такую реализацию (исходный код очищен для наглядности):

    Vue.mixin({
        beforeCreate() {
          const { wait, store } = this.$options;
    
          let instance = null;
          instance.init(Vue, store); // inject to store
          this.$wait = instance; // inject to app
        }
      });
    

    Вместо прототипа предлагается использовать глобальный mixin. Эффект в общем-то тот же, возможно, за исключением каких-то нюансов. Но учитывая, что и в store inject делается здесь же, выглядит не совсем right way и совсем не соответствует описанному в документации.

    А если все же prototype?


    Идея решения с прототипом, которая используется в коде функции inject была позаимствована у Nuxt. Выглядит она намного более right way, чем глобальный mixin, поэтому я остановился на ней.

        Vue.use(() => {
          // проверяем, что такого свойства еще нет в прототипе
          if (Vue.prototype.hasOwnProperty(key)) {
            return;
          }
          // определяем новое свойство прототипа, взяв его значение из ранее добавленной в объект app переменной
          Object.defineProperty(Vue.prototype, key, {
            get () {
              return this.$root.$options[key]; // геттер нужен, чтобы использовать контекст this
            }
          });
        });
    


    Результат


    После этих манипуляций мы получаем возможность обратиться к this.$wait из любого компонента, а также любого метода в store.

    Что получилось


    Структура проекта:

    src
    - plugins
    -- index.js
    -- axios.js
    -- wait.js
    - store
    - App.vue
    - main.js


    index.js:
    import Vue from 'vue';
    import axios from './axios';
    import wait from './wait';
    
    export default function (app) {
      let inject = (name, plugin) => {
        let key = `$${name}`;
        app[key] = plugin;
        app.store[key] = plugin;
    
        Vue.use(() => {
          if (Vue.prototype.hasOwnProperty(key)) {
            return;
          }
          Object.defineProperty(Vue.prototype, key, {
            get () {
              return this.$root.$options[key];
            }
          });
        });
      };
    
      axios(app, inject);
      wait(app, inject);
    }
    


    wait.js
    export default function ({store: {dispatch, getters}}, inject) {
      const wait = {
        start: action => dispatch('wait/start', action),
        end: action => dispatch('wait/end', action),
        is: action => getters['wait/waiting'](action)
      };
    
      inject('wait', wait);
    }
    


    axios.js
    import axios from 'axios';
    
    export default function (app) {
      axios.defaults.baseURL = process.env.API_BASE_URL;
      axios.defaults.headers.common['Accept'] = 'application/json';
      axios.defaults.headers.post['Content-Type'] = 'application/json';
    }
    


    main.js:
    import Vue from 'vue';
    import App from './App.vue';
    import store from './store';
    import initPlugins from './plugins';
    
    const app = {
      render: h => h(App),
      store
    };
    
    initPlugins(app); 
    
    new Vue(app).$mount('#app');
    

    Заключение


    В результате проведенных манипуляций мы получили один импорт и один вызов функции в файле main.js. А также теперь сразу понятно, где искать конфиг для каждого плагина и каждую глобальную зависимость.

    При добавлении нового плагина нужно всего лишь создать файл, который экспортирует функцию, импортировать его в index.js и вызвать эту функцию.

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

    Делитесь своим опытом организации зависимостей в комментариях. Успешных проектов!

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 12

      +3
      А в чем смысл навешивания разнообразных плагинов на центральный объект? Можно же просто импортировать библиотеки по месту использования – сразу будет понятно что откуда тянется.

      Мне кажется от миксинов в прототипы отказались сразу после jQuery, с его системой плагинов через jQuery.fn.functionName
        0
        Смысл такой же, как и в использовании глобальных миксинов и компонентов. Чтобы не делать импорт во всех файлах, если библиотека используется часто.
        Понятно, что применять подобные зависимости надо обдуманно, чтобы не засорять глобальный экземпляр Vue.
          0
          Отсутствие лишний писанины это хороший аргумент.

          Но с другой стороны, без явных прописанных импортов будет сложно отследить, где используется какая-то библиотека. А это нужно, когда проводится апгрейд версии, или чтобы удалить библиотеку после рефакторинга.
          0
          Мне кажется зависит от того должна ли бы зависимость синглтоном с общим для приложения стейтом (например router) или же это просто набор утилит.
            +1
            Все верно. Для центровых штук, вроде vue-router или vuex это уместно. А вот простым утилитам эта магия ни к чему.
              0
              Все так.
          0
          спасибо
            +1

            Имхо, слишком запутанная схема. Вы передаете экземпляр app в плагин, где он так вообще ни разу и не используется, и туда же передаете inject() только для того, чтобы плагин зарегистрировал себя сам. По мне, что такое было бы намного читабельнее и очевиднее:


            registerPrototype('axios', axios);
            registerPrototype('wait', wait);

            Плюс, такой способ позволил бы возвращать инстанс плагина из функции, чтобы его можно было импортировать вручную. Доступ через глобальный объект удобен изнутри компонента, а в слое сервисов я бы предпочел явным образом импортированные зависимости.

              0
              1) В модуле axios.js, не дописан inject
              inject('http', axios);


              2) Почему плагин не добавить канонически, как по доках

              Вот как у меня все получилось

              plugins/index.js
              import Vue from 'vue';
              import axios from './axios';
              import wait from './wait';

              export default function (app) {

              let inject = (plugin) => {
              Object.assign(app.store, plugin);
              Vue.use({install(Vue) { Object.assign(Vue.prototype, plugin)}});
              }

              axios(app, inject);
              wait(app, inject);
              }


              wait.js
              export default function ({store: {dispatch, getters}}, inject) {
              const $wait = {
              ...
              };

              inject( { $wait } );
              }
                0
                1) Конкретно в моем проекте нет необходимости делать inject для axios. Вызовы API я храню отдельно, в компонентах и сторе this.$axios я не использую. В статье пример с axios был с целью показать конфигурирование библиотек внутри плагинов, а не Dependency Injection в корневой экземпляр Vue.

                2) Такой способ из документации у меня не сработал, это упомянуто в статье.
                Vue.prototype.$myMethod = function (methodOptions) {
                    // некоторая логика ...
                }
                

                Делать Object.assign в функции install вместо простого присваивания не пробовал.
                  0
                  ну так там же не просто присваивание прототипу нового метода, а там идет через создание объекта с методом install, уже в котором идет присваивание прототипа, который уже подключается через
                  Vue.use(...)
                  Это реально рабочий вариант, не только у меня. Попробуйте, таким способом сделать и тогда заработает.
                    0
                    Да, именно так и пробовал в первый раз. Создать плагин с методом install. И там сделать это присвоение. Очень удивился, что не заработало, очевидных косяков не обнаружилось, поэтому начал смотреть, как сделано у других.

                    Оба варианта — с миксином из vue-wait и с Object.defineProperty из Nuxt заработали сразу. И они мало похожи на то, что описано в документации. Вероятно, не без причины.
                    Как-нибудь попробую ваш вариант, спасибо.

              Only users with full accounts can post comments. Log in, please.