10 lines of code to reduce the pain of your Vue project

... or familiarity with Vue JS plugins as an example of an integrated event bus


A few words about ...


Hello everyone! Iā€™ll make a reservation right away. I really love VueJS, I have been actively writing on it for more than 2 years and I do not think that development on it can hurt at least to some significant degree :)
On the other hand, we are always trying to find universal solutions that will help to spend less time on mechanical work and more on what is really interesting. Sometimes the solution is particularly successful. One of these I want to share with you. The 10 lines that will be discussed (spoiler: at the end there will be a little more) were born in the process of working on the Cloud Blue - Connect project, which is a fairly large application with 400+ components. The solution we found is already integrated into various points of the system and for more than half a year it has never required corrections, so it can be safely considered successfully tested for stability.

And the last one. Before proceeding directly to the solution, I would like to dwell a little more on the description of the three types of interaction between Vue components among themselves: the principles of unidirectional flow, the pattern of the store, and the event bus. If this explanation is unnecessary (or boring) for you, go directly to the section with the solution - everything is as brief and technical as possible.

A little bit about how Vue components communicate with each other


Perhaps the first question that a person who has written his first component raises is how he will receive data for work and how, in turn, he will transmit the data he received ā€œoutā€. The interaction principle adopted in the Vue JS framework is called ...

Unidirectional data stream


In short, this principle sounds like "properties - down, events - up." That is, to receive data from the outside (ā€œfrom aboveā€), we register a special property inside the component into which the framework writes, if necessary, our data received ā€œfrom the outsideā€. In order to transfer data ā€œupā€, inside the component in the right place, we call the special $ emit framework method, which passes our data to the handler of the parent component. At the same time, in Vue JS we canā€™t just ā€œbroadcastā€ the event up to unlimited depth (as for example in Angular 1.x). It "pops up" only one level, to the immediate parent. The same goes for events. To transfer them to the next level, for each of them you also need to register a special interface - properties and events that will transmit our ā€œmessageā€ further.

This can be described as an office building in which workers can only move from their floors to the neighboring ones - one up and one down. So, in order to transfer the ā€œdocument for signatureā€ from the fifth floor to the second, a chain of three workers will be required who will deliver it from the fifth floor to the second, and then three more who will deliver it back to the fifth.

ā€œBut this is inconvenient!ā€ Of course, this is not always convenient from a development point of view, but looking at the code of each component, we can see what and to whom it passes. We do not need to keep in mind the entire structure of the application in order to understand whether our component is ā€œon the wayā€ to the event or not. We can see this from the parent component.

Although the advantages of this approach are understandable, it also has obvious disadvantages, namely, the high cohesion of components. Simply put, in order for us to place some component in the structure, we need to overlay it with the necessary interfaces in order to manage its state. In order to reduce this connectivity, they often use ā€œstate management toolsā€. Perhaps the most popular tool for Vue is ...

Vuex (side)


Continuing our analogy with an office building, Vuex Stor is an internal postal service. Imagine that on each floor of the office there is a window for issuing and receiving parcels. On the fifth floor they transfer document No. 11 for signature, and on the second they periodically ask: ā€œAre there any documents for signature?ā€, Sign the existing ones and give them back. On the fifth one they also ask: ā€œAre there any signatories?ā€ At the same time, employees can move to other floors or to other rooms - the principle of work will not change while the mail is working.

Approximately by this principle the pattern called Store also works. Using the Vuex interface, a global data warehouse is registered and configured, and components subscribe to it. And it doesnā€™t matter at what level of what structure the appeal occurred, the store will always give the right information.

It would seem that on this all the problems have already been resolved. But at some point in our metaphorical building, one employee wants to call another for lunch ... or report some kind of mistake. And here the strange begins. The message itself does not require transmission as such. But in order to use the mail you need to transfer something. Then our employees come up with a code. One green ball - go to lunch, two red cubes - an application error E-981273 occurred, three yellow coins - check your mail and so on.

It is easy to guess that with the help of this awkward metaphor I describe situations when we need to ensure the response of our component to an event that occurred in another component, which in itself is not connected in any way with the data stream. Saving of a new item is completed - you need to retake the collection. An 403 Unauthorized error has occurred - you need to start a user logout and so on. The usual (and far from the best) practice in this case is to create flags inside the store or to indirectly interpret the stored data and its changes. This quickly leads to pollution of both the store itself and the logic of the components around it.

At this stage, we begin to think about how to pass events directly, bypassing the entire chain of components. And, a little google or rummaging in the documentation, we come across a pattern ...

Event bus


From a technical point of view, the event bus is an object that allows using one special method to launch an ā€œeventā€ and subscribe to it using another. In other words, when signing up for the eventA event, this object stores the passed-in handler function inside its structure, which it will call when the launch method with the eventA key is called somewhere in the application. To sign or run it is enough to access it through import or by reference, and you're done.

Metaphorically, in our ā€œbuildingā€, a bus is a common chat in the messenger. Components subscribe to a "general chat" to which other components send messages. As soon as a ā€œmessageā€ appears on the ā€œchatā€, to which the component has subscribed, the handler will start.

There are many different ways to create an event bus. You can write it yourself or you can use ready-made solutions - the same RxJS, which provides huge functionality for working with entire streams of events. But most often when working with VueJS they use, oddly enough, VueJS itself. The Vue instance created through the constructor (new Vue ()) provides a beautiful and concise event interface, described in the official documentation.

Here we come close to the next question ...

What do we want?


And we want to build an event bus in our application. But we have two additional requirements:

  1. It should be easily accessible in every component. Separate imports into each of the dozens of components seems redundant to us.
  2. It must be modular. We do not want to keep all event names in mind in order to avoid the situation when the ā€œitem-createdā€ event fires handlers from the entire application. Therefore, we want to be able to easily separate a small fragment of the component tree into a separate module and broadcast its events inside it, and not outside.

In order to implement such impressive functionality, we use the powerful plug-in interface that VueJS provides us with. You can familiarize yourself with it in more detail here on the page with official documentation.

Let's register our plugin first. To do this, right before the initialization point of our Vue application (before calling Vue. $ Mount ()) we place the following block:

Vue.use({   
  install(vue) { }, 
});

In fact, Vue plugins are a way to extend the functionality of the framework at the whole application level. The plugin interface provides several ways to integrate into the component, but today we will introduce the mixin interface. This method accepts an object that extends the descriptor of each component before starting the life cycle in the application.(The component code that we write is more likely not the component itself, but a description of its behavior and encapsulation of a certain part of the logic that the framework uses at various stages of its life cycle. The plug-in initialization is outside the component life cycle, preceding it, therefore we we say ā€œdescriptorā€, not a component, to emphasize that exactly the code that is written in our file, and not some entity that is a product of the frameworkā€™s work, will be transferred to the mixin section of the plugin) .

Vue.use({
  install(vue) {     
    vue.mixin({}); // <--
  }, 
});

It is this empty object that will contain the extensions for our components. But for starters, another stop. In our case, we want to create an interface for accessing the bus at the level of each component. Let's add the '$ broadcast' field to our descriptor, it will store a link to our bus. To do this, use Vue.prototype:

Vue.use({   
  install(vue) { 
    vue.prototype.$broadcast = null; // <--
    vue.mixin({}); 
  }, 
});

Now we need to create the bus itself, but first let's recall the requirement of modularity and assume that in the component descriptor we will declare a new module with the ā€œ$ moduleā€ field with some text value (we will need it a bit later). If the $ module field is specified in the component itself, we will create a new bus for it; if not, we will pass the link to the parent via the $ parent field. Note that the descriptor fields will be available to us through the $ options field.

We will place the creation of our bus at the earliest possible stage - in the beforeCreate hook.

Vue.use({
  install(vue) { 
    vue.prototype.$broadcast = null; 
    vue.mixin({
      beforeCreate() {  // <--
        if (this.$options.$module) {  // <--
         
 	} else if (this.$parent && this.$parent.$broadcast) {  // <--
         
        } 
      }, 
    }); 
  }, 
});

Finally, let's fill in the logical branches. If the descriptor contains a new module declaration, create a new bus instance, if not, take the link from $ 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;  // <--
        } 
      }, 
    }); 
  }, 
});

We discard the pluginā€™s announcement, we consider ... 1, 2, 3, 4 ... 10 lines, as I promised!

Can we do it better?


Of course we can. This code is easily extensible. For example, in our case, in addition to $ broadcast, we decided to add the $ rootBroadcast interface, which gives access to a single bus for the entire application. Events that the user runs on the $ broadcast bus are duplicated on the $ rootBroadcast bus so that you can subscribe to either all events of a particular module (in this case, the event name will be passed to the handler as the first argument) or to all application events in general (then the module name will be passed to the handler with the first argument, the event name with the second, and the data transmitted with the event will be passed with the following arguments). This design will allow us to establish interaction between the modules, as well as hang a single handler on the events of different modules.

// 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) => {ā€¦});

Let's see how we can achieve this:

First, create a single bus, which will be organized through $ rootBroadcast, and the field itself with a link:

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; 
        } 
      }, 
    }); 
  }, 
});

Now we need module membership in each component, so let's expand the definition of modularity like this:

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; 
        } 
      }, 
    }); 
  }, 
});

Next, we need to make the event on the modular local bus reflect in the way we need to the root. To do this, we first have to create a simple proxy interface and place the bus itself in the conditionally private property of $ 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 };  // <--
        } 
      }, 
    }); 
  }, 
});

And finally, add proxy methods to the object - because now the $ broadcast field does not provide direct access to the 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 };
        } 
        // >>>
        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);
        };
        // <<<
      }, 
    }); 
  }, 
});

Well, as a final touch, let's remember that we get access to the bus by closing, which means that the handlers added once will not be cleared with the component, but will live during the whole time of working with the application. This can cause unpleasant side effects, so let's add a listener cleanup function to our bus at the end of the componentā€™s life cycle:

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);   // <--
        };
      }, 
    }); 
  }, 
});

Thus, this option provides a more interesting functionality, although less concise. With it, you can implement a complete system of alternative communication between components. Moreover, he is completely under our control and does not bring external dependencies into our project.

I hope that after reading you acquired or refreshed your knowledge of Vue plugins, and perhaps the next time you need to add some generic functionality to your application, you can implement it more efficiently - without adding external dependencies.

All Articles