… или знакомство с плагинами Vue JS на примере интегрированной шины событий
Пара слов о…
Всем привет! Сразу оговорюсь. Я очень люблю VueJS, активно пишу на нем уже больше 2-х лет и не считаю, что разработка на нем может причинить боль хоть в какой-то значимой степени :)
С другой стороны, мы всегда пытаемся найти универсальные решения, которые помогут тратить меньше времени на механическую работу и больше – на то, что действительно интересно. Иногда решение оказывается особенно удачным. Одним из таких я хочу поделиться с вами. 10 строк, о которых пойдет речь (спойлер: в конце их окажется немного больше), родились в процессе работы над проектом Cloud Blue – Connect, который представляет собой достаточно крупное приложение на 400+ компонентов. Найденное нами решение уже интегрировано в самые разные точки системы и вот уже более полугода ни разу не требовало правок, поэтому его смело можно считать успешно проверенным на устойчивость.
И последнее. Перед тем, как непосредственно перейти к решению, я хотел бы немного подробнее остановиться на описании трех типов взаимодействия компонентов Vue между собой: принципах однонаправленного потока, паттерн стора и шины событий. Если для вас это объяснение лишнее (или скучное), переходите сразу к разделу с решением – там все максимально кратко и технично.
Немного о том как компоненты Vue общаются между собой
Пожалуй, первый вопрос, который возникает у человека, написавшего свой первый компонент, касается того, как он получит данные для работы и как, в свою очередь, передаст данные, полученные им, «наружу». Принцип взаимодействия, принятый во фреймворке Vue JS, называется…
Однонаправленный поток данных
Если коротко, этот принцип звучит как «свойства — вниз, события — вверх». То есть для получения данных снаружи («сверху») мы регистрируем внутри компонента специальное свойство, в которое фреймворк при необходимости записывает наши данные, полученные «снаружи». Для того же, чтобы передать данные «наверх», внутри компонента в нужном месте мы вызываем специальный метод фреймворка $emit, который передает наши данные в обработчик родительского компонента. При этом во Vue JS мы не можем просто «транслировать» событие вверх на неограниченную глубину (как например в Angular 1.x). Оно «всплывает» только на один уровень, до непосредственного родителя. То же касается и событий. Чтобы передать их на следующей уровень, для каждого из них также нужно зарегистрировать специальный интерфейс – свойства и события, которые передадут наше «сообщение» дальше.
Это можно описать как офисное здание, в котором работники могут переходить со своего этажа только на соседние – один наверх и один вниз. Так, чтобы передать «документ на подпись» с пятого этажа на второй, потребуется цепочка из трех работников, которые доставят его с пятого этажа на второй, и потом еще трое, которые доставят его обратно на пятый.
«Но это же неудобно!» Конечно, это не всегда удобно с точки зрения разработки, зато, глядя на код каждого компонента, нам видно, что и кому он передает. Нам не нужно держать в голове всю структуру приложения, чтобы понять, находится наш компонент «на пути» события или нет. Мы можем увидеть это из компонента родителя.
Хотя преимущества этого подхода понятны, у него есть и очевидные недостатки, а именно высокая связанность компонентов. Проще говоря для того, чтобы нам поместить какой-то компонент в структуру, нужно обложить его необходимыми интерфейсами, чтобы управлять его состоянием. Для того, чтобы уменьшить эту связанность, чаще всего используют «инструменты управления состояниями». Пожалуй, самый популярный инструмент для Vue это…
Vuex (сторы)
Продолжая нашу аналогию с офисным зданием, Vuex стор – это внутренняя почтовая служба. Представим, что на каждом этаже офиса есть окно выдачи и приема посылок. На пятом этаже передают документ №11 на подпись, а на втором периодически спрашивают: «Есть ли документы на подпись?», подписывают имеющиеся и отдают их обратно. На пятом так же спрашивают: «А есть ли подписанные?». При этом работники могут переехать на другие этажи или в другие помещения – принцип работы не поменяется, пока почта работает.
Примерно по такому принципу и работает паттерн, который называется Store. С помощью интерфейса Vuex регистрируется и настраивается некоторое глобальное хранилище данных, а компоненты подписываются на него. Причем не важно, с какого уровня какой структуры произошло обращение, store всегда выдаст нужную информацию.
Казалось бы, на этом все проблемы уже решены. Но в какой-то момент в нашем метафорическом здании один сотрудник хочет позвать другого на обед… или сообщить о какой-то ошибке. И здесь начинается странное. Само по себе сообщение не требует передачи как таковой. Но для того, чтобы воспользоваться почтой надо что-то передать. Тогда наши сотрудники придумывают шифр. Один зеленый шарик – идем на обед, два красных кубика – произошла ошибка приложения E-981273, три желтые монетки – проверь почту и так далее.
Нетрудно догадаться, что с помощью этой неуклюжей метафоры я описываю ситуации, когда нам нужно обеспечить реакцию нашего компонента на событие, произошедшее в другом компоненте, которое само по себе никак не связано с потоком данных. Завершено сохранение нового элемента – требуется переспросить коллекцию. Произошла ошибка 403 Unauthorized – требуется запустить выход пользователя из системы и так далее. Обычная (и далеко не лучшая) практика в таком случае – создание флагов внутри стора или косвенная интерпретация хранимых данных и их изменений. Это быстро приводит к загрязнению как самого стора, так и логики компонентов вокруг него.
На этом этапе мы начинаем задумываться о том, как передавать события напрямую, минуя всю цепочку компонентов. И, немного погуглив или покопавшись в документации, натыкаемся на паттерн…
Шина событий
С технической точки зрения шина событий – это объект, который позволяет с помощью одного специального метода запускать «событие» и подписываться на него с помощью другого. Иначе говоря, при подписании на событие «eventA» этот объект сохраняет внутри своей структуры переданную функцию-обработчик, которую он вызовет, когда где-то в приложении будет вызван метод запуска с ключом «eventA». Для подписания или запуска достаточно получить к нему доступ через импорт или по ссылке, и готово.
Метафорически в нашем «здании» шина – это общие чаты в мессенджере. Компоненты подписываются на «общий чат», в который другие компоненты отправляют сообщения. Как только в «чате» появится «сообщение», на которое подписался компонент, запустится обработчик.
Существует множество разных способов создать шину событий. Ее можно написать самостоятельно или можно воспользоваться готовыми решениями – тем же RxJS, который предоставляет огромный функционал для работы с целыми потоками событий. Но чаще всего при работе с VueJS используют, как ни странно, сам VueJS. Экземпляр Vue, созданный через конструктор (new Vue()), предоставляет прекрасный и лаконичный интерфейс событий, описанный в официальной документации.
Здесь мы вплотную подходим к следующему вопросу…
Чего же мы хотим?
А хотим мы встроить в наше приложение шину событий. Но у нас есть два дополнительных требования:
- Она должна быть легко доступна в каждом компоненте. Отдельный импорт в каждый из десятков компонентов нам кажется избыточным.
- Она должна быть модульной. Мы не хотим держать в голове все имена событий, чтобы избежать ситуации, когда событие «item-created» запускает обработчики со всего приложения. Поэтому мы хотим, чтобы можно было легко отделить небольшой фрагмент дерева компонентов в отдельный модуль и транслировать его события внутри него, а не снаружи.
Для того, чтобы реализовать такой впечатляющий функционал, мы используем мощный интерфейс плагинов, который нам предоставляет VueJS. Ознакомиться с ним подробнее можно здесь, на странице с официальной документацией.
Давайте для начала зарегистрируем наш плагин. Для этого прямо перед точкой инициализации нашего Vue приложения (перед вызовом Vue.$mount()) поместим следующий блок:
Vue.use({
install(vue) { },
});
Фактически плагины Vue это способ расширения функционала фреймворка на уровне всего приложения. Интерфейс плагинов реализует несколько способов встроиться в компонент, но сегодня мы познакомимся с интерфейсом mixin. Этот метод принимает объект, который расширяет дескриптор каждого компонента перед началом жизненного цикла в приложении. (Код компонентов, который мы пишем, является скорее не самим компонентом, а описанием его поведения и инкапсуляцией определенной части логики, которая в процессе жизненного цикла используется фреймворком на разных его этапах. Инициализация плагина находится за пределами жизненного цикла компонента, предваряя его, поэтому мы говорим «дескриптор», а не компонент, чтобы подчеркнуть, что внутрь mixin секции плагина будет передан именно тот код, который написан в нашем файле, а не какая-то сущность, которая является продуктом работы фреймворка).
Vue.use({
install(vue) {
vue.mixin({}); // <--
},
});
Именно этот пустой объект будет содержать расширения для наших компонентов. Но для начала еще одна остановка. В нашем случае мы хотим создать интерфейс для доступа к шине на уровне каждого компонента. Давайте добавим к нашему дескриптору поле ‘$broadcast’ (англ. — «эфир»), оно будет хранить ссылку на нашу шину. Для этого воспользуемся Vue.prototype:
Vue.use({
install(vue) {
vue.prototype.$broadcast = null; // <--
vue.mixin({});
},
});
Теперь нам нужно создать саму шину, но сначала давайте вспомним про требование модульности и примем, что в дескрипторе компонента мы будем объявлять новый модуль полем «$module» с каким-то текстовым значением (оно нам понадобится немного позже). Если поле $module будет задано в самом компоненте, создадим для него новую шину, если же нет — передадим ссылку на родительскую через поле $parent. При этом обратим внимание, что поля дескриптора будут нам доступны через поле $options.
Поместим создание нашей шины на как можно более ранний этап – в хук beforeCreate.
Vue.use({
install(vue) {
vue.prototype.$broadcast = null;
vue.mixin({
beforeCreate() { // <--
if (this.$options.$module) { // <--
} else if (this.$parent && this.$parent.$broadcast) { // <--
}
},
});
},
});
И наконец давайте заполним логические ветви. В случае, если дескриптор содержит объявление нового модуля, создадим новый экземпляр шины, если нет, возьмем ссылку из $parent.
Vue.use({
install(vue) {
vue.prototype.$broadcast = null;
vue.mixin({
beforeCreate() {
if (this.$options.$module) {
this.$broadcast = new Vue(); // <--
} else if (this.$parent && this.$parent.$broadcast) {
this.$broadcast = this.$parent.$broadcast; // <--
}
},
});
},
});
Отбрасываем объявление плагина, считаем… 1, 2, 3, 4 … 10 строчек, как я и обещал!
А можем еще лучше?
Конечно, можем. Этот код легко расширяется. Например, в нашем случае мы решили помимо $broadcast добавить интерфейс $rootBroadcast, который дает доступ к единой для всего приложения шине. События, которые пользователь запускает на шине $broadcast, дублируются на шине $rootBroadcast таким образом, чтобы можно было подписаться либо на все события конкретного модуля (в этом случае первым аргументом в обработчик будет передано имя события), либо на все события приложения вообще (тогда имя модуля будет передано в обработчик первым аргументом, имя события — вторым, а данные, переданные с событием, будут передаваться следующими аргументами). Такая конструкция позволит нам наладить взаимодействие между модулями, а также вешать единый обработчик на события разных модулей.
// This one emits event
this.$broadcast.$emit(‘my-event’, ‘PARAM_A’);
// This is standard subscription inside module
this.$broadcast.$on(‘my-event’, (paramA) => {…});
// This subscription will work for the same event
this.$rootBroadcast.$on(‘my-event’, (module, paramA) => {…});
// This subscription will also work for the same event
this.$rootBroadcast.$on(‘*’, (event, module, paramA) => {…});
Давайте посмотрим, как нам этого добиться:
Во-первых, создадим единую шину, к которой будет организован доступ через $rootBroadcast, и само поле со ссылкой:
const $rootBus = new Vue(); // <--
Vue.use({
install(vue) {
vue.prototype.$broadcast = null;
vue.mixin({
beforeCreate() {
vue.prototype.$rootBroadcast = $rootBus; // <--
if (this.$options.$module) {
this.$broadcast = new Vue();
} else if (this.$parent && this.$parent.$broadcast) {
this.$broadcast = this.$parent.$broadcast;
}
},
});
},
});
Теперь нам необходима принадлежность к модулю в каждом компоненте, поэтому давайте расширим определение модульности вот так:
const $rootBus = new Vue();
Vue.use({
install(vue) {
vue.prototype.$broadcast = null;
vue.mixin({
beforeCreate() {
vue.prototype.$rootBroadcast = $rootBus;
if (this.$options.$module) {
this.$module = this.$options.$module; // <--
this.$broadcast = new Vue();
} else if (this.$parent && this.$parent.$broadcast) {
this.$module = this.$parent.$module; // <--
this.$broadcast = this.$parent.$broadcast;
}
},
});
},
});
Далее нам нужно сделать так, чтобы событие на модульной локальной шине отражалось нужным нам образом на корневой. Для этого нам сначала придется создать простой прокси интерфейс и разместить саму шину в условно приватном свойстве $bus:
const $rootBus = new Vue();
Vue.use({
install(vue) {
vue.prototype.$broadcast = null;
vue.mixin({
beforeCreate() {
vue.prototype.$rootBroadcast = $rootBus;
if (this.$options.$module) {
this.$module = this.$options.$module;
this.$broadcast = { $bus: new Vue() }; // <--
} else if (this.$parent && this.$parent.$broadcast) {
this.$module = this.$parent.$module;
this.$broadcast = { $bus: this.$parent.$broadcast.$bus }; // <--
}
},
});
},
});
И наконец добавим к объекту проксирующие методы — ведь теперь поле $broadcast не предоставляет прямого доступа к шине:
const $rootBus = new Vue();
Vue.use({
install(vue) {
vue.prototype.$broadcast = null;
vue.mixin({
beforeCreate() {
vue.prototype.$rootBroadcast = $rootBus;
if (this.$options.$module) {
this.$module = this.$options.$module;
this.$broadcast = { $bus: new Vue() };
} else if (this.$parent && this.$parent.$broadcast) {
this.$module = this.$parent.$module;
this.$broadcast = { $bus: this.$parent.$broadcast.$bus };
}
// >>>
this.$broadcast.$emit = (…attrs) => {
this.$broadcast.$bus.$emit(…attrs);
const [event, …attributes] = attrs;
this.$rootBroadcast.$emit(event, this.$module, …attributes));
this.$rootBroadcast.$emit(‘*’, event, this.$module, …attributes)
};
this.$broadcast.$on = (…attrs) => {
this.$broadcast.$bus.$on(…attrs);
};
// <<<
},
});
},
});
Ну и в качестве последнего штриха давайте вспомним, что мы получаем доступ к шине по замыканию, а это значит что обработчики добавленные однажды не очистятся с компонентом, но будут жить в течении всего времени работы с приложением. Это может вызвать неприятные сайд-эффекты, поэтому давайте добавим к нашей шине функцию очистки слушателей с окончанием жизненного цикла компонента:
const $rootBus = new Vue();
Vue.use({
install(vue) {
vue.prototype.$broadcast = null;
vue.mixin({
beforeDestroy() { // <--
this.$broadcast.$off(this.$broadcastEvents); // <--
},
beforeCreate() {
vue.prototype.$rootBroadcast = $rootBus;
this.$broadcastEvents = []; // <--
if (this.$options.$module) {
this.$module = this.$options.$module;
this.$broadcast = { $bus: new Vue() };
} else if (this.$parent && this.$parent.$broadcast) {
this.$module = this.$parent.$module;
this.$broadcast = { $bus: this.$parent.$broadcast.$bus };
}
this.$broadcast.$emit = (…attrs) => {
this.$broadcastEvents.push(attrs[0]); // <--
this.$broadcast.$bus.$emit(…attrs);
const [event, …attributes] = attrs;
this.$rootBroadcast.$emit(event, this.$module, …attributes));
this.$rootBroadcast.$emit(‘*’, event, this.$module, …attributes)
};
this.$broadcast.$on = (…attrs) => {
this.$broadcast.$bus.$on(…attrs);
};
this.$broadcast.$off =: (...attrs) => { // <--
this.$broadcast.$bus.$off(...attrs); // <--
};
},
});
},
});
Таким образом, этот вариант предоставляет более интересный функционал, хотя и менее лаконичный. С его помощью можно реализовать полноценную систему альтернативной коммуникации между компонентами. При этом он полностью находится под нашим контролем и не привносит внешних зависимостей в наш проект.
Надеюсь, после прочтения вы приобрели или освежили для себя знания по плагинам Vue, и, возможно, когда в следующий раз вам потребуется добавить в ваше приложение какой-то генеричный функционал, вы сможете реализовать его эффективнее – без добавления внешних зависимостей.