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

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

svfaxnl2uh7vozipy4n5to6qpqm.png

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


Многие могут подумать — зачем это нужно, если есть, например, 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 или сконфигурировать очередной плагин.

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

© Habrahabr.ru