Creating a Frontend Architecture With Dynamic Plugins
Join the DZone community and get the full member experience.
Join For FreeThe following are some of the most used approaches to handle pluggability on frontend:
- The main application works like a layout for all the features it contains, where each feature has switch on/switch off functionality. If a plugin is present, it will be displayed in a certain place. But, if you want to develop a new plugin, you will need to modify the main application, so it will be aware about it.
- Load plugins dynamically and add them to the main application as sub-applications in an iframe. That gives certain flexibility, as you can use different versions of the same third-party libraries, but there are also some costs, including:
- The bundle size blows really fast. All required third-party plugins have to be included inside the plugin again.
- To reuse already written logic in a core plugin, you either have to copy and paste it or create a shared-module with common functionality and include it in the core and custom plugin. In this latter scenario, when this shared functionality is different from plugin to plugin, it can become a mess really quickly.
- It will not allow you to bring smallchanges to an application, like replacing a button with a new one on the fly.
Keeping these limitations in mind, let’s have a look at a new approach. First, I will explain it with a simple example and then on a more advanced level.
As an easy example, we can model an application, where all custom plugins will be automatically registered to a navigation panel. Let’s take a look at a navigation panel inside of our main core UI plugin:
We want any new plugin to be added to the panel. We have no idea how the new plugin is going to communicate with a main application. Therefore to make it work, we need to define some conventions and communicational channels:
- We have to define how we are going to detect UI plugins among other types of plugins.
- Each plugin should contain metadata in a uniform format:
- Give a title to the plugin in the navigation panel.
- Define the place for the tab; otherwise, the order of custom tabs will be uncontrollable.
- Specify the location of bundles that will be loaded to a page.
- Define permissions and load plugins based on role.
- Create an event-based communication channel between the core application and plugin and between plugins.
On top of that, we need to perform a validation that third-party libraries should be of the same version, as plugins will not bundle a third-party library, but rather, reuse it from the main application.
With these requirements in mind, let's try to go deeper into details and make our panel look like this (after custom plugin is loaded):
As you might have noticed, there is an extra “Reports” tab added at the navbar's second position.
We have to define how we are going to detect UI plugins among other types of plugins
For that, we can add a file descriptor to each UI plugin that will indicate that it is a custom UI plugin, i.e. custom-ui-extension.json. With this, when we scan the folder/classpath, we can filter for new plugins.
Each plugin should contain a metadata in the uniform format.
Let’s design how this might look for above-mentioned requirements:
{
"entry": "reports-bundle.js",
"name": "reports-plugin",
"weight": 15,
"permissions": [
"admin"
],
"title": "Reports"
}
Title is defined obviously.
For the position of tabs, I took the assumption that the core plugin tabs were built in the order where the first tab has a “weight” 10, second 20, etc.
Custom plugins can identify themselves between which tabs they need to be placed between. There is still room to add extra plugins if several custom plugins have to be squeezed in between “Home" and “Shop” tabs.
If two plugins have the same weight, they can be ordered with some extra logic, like place them alpha-numerically so the position will not be applied randomly. The entry field contains information about the bundle and location. It can be for example: ${plugin-name}/web/${entry}.
As an alternative, you can define in the metadata, the full path to the bundle file and give flexibility of UI plugins to have a different structure. The permissions field containing the list of restricted permission names for which this plugin will have an effect. If permissions is an empty list — then, there's open access.
Create an event-based communication channel between core application and plugin and between plugins
The benefit of this type of communication channel is in loose coupling between plugins. If some events cannot be handled, they will be ignored, and the system can proceed to work normally. If the system is going to be huge or you want a customer to have the ability to create their own UI plugins, you need to create a detailed API. Which in own side will also increase a quality of it, as you will need to justify in documents all events/fields why they needed and what they do.
Now, we have logic for how we are going to get information about plugins and communicate between them. Let’s see how they have to be dynamically added to a core application.
Once the bundle of a plugin is loaded, we need to dynamically append the script to the body of the document object for the page:
xxxxxxxxxx
const script = document.createElement('script');
script.src = scriptUrl; // fully qualified path to the loaded script
script.async = true;
document.body.appendChild(script);
Now, all scripts which were in the plugin are accessible from the core application. And for that, we need to have some convention how the custom plugins built. With current example would be enough to have one export object containing:
xxxxxxxxxx
{
component: ,
eventChannel:
}
Component — you dynamically render on a newly created tab (so that also means that you need to have a logic that will dynamically create tabs in this lifecycle) and also register eventChannel
in the system. And, it will depend on technology. For example, with Redux/Redux-saga you will need to register reducers and run sagas (how to do this with these technologies, I’ll cover in another article of implementing this approach by using these tools).
When you build a custom plugin, you need to mark all third-party libraries as external, so they won’t be included in a bundle. Once the code is mingled, the required libraries will be taken from the main application.
One thing you might consider is that to create a development environment for speed development, as I might assume that you don’t want to boot up the whole infrastructure but in the same time being able to use the communication channel from core application for quick testing. For that, you can ship that part from the core application as a separate small module and use it only for development purposes.
That's basically it; the approach works. It gives you really smooth integration, as it was developed in one git repository, has no duplicated libraries loaded or duplicated code between core and custom plugin, and you can easily communicate with each module natively, without any iframe bridges.
Let’s now have a look at a more complicated scenario. Let’s assume I want on a home page to update an existing button with a button with dropdown.
Approach:
- Use hierarchical identifications of elements in the core application:
- Like tab has attribute: component-id=“home".
- Button has attribute: component-id=“open-profile".
- Specify in the plugin for each component where it should be placed. So then, the entry object will look like:
xxxxxxxxxx
{
overrides: [{
replace-component-having-path: ‘home/open-profile’
component:
}]
}
And metadata will require fewer fields for such type of plugin:
xxxxxxxxxx
{
"entry": “home-addons-bundle.js",
"name": “home-addons-plugin"
}
Then, the logic is to find that component with the help of CSS selectors. I made the original one invisible and place a new component instead. The same can be done for all buttons, and then you don’t need a hierarchical structure. Instead, find all the components with the desired classifier and replace them.
With this approach, you only bundle add-ons in this plugin. But it’s not necessarily a technical limitation to separate each plugin like that. It can be one bundle containing and add-ons and several tabs. I wouldn’t do it like that, but if somebody prefers this method, then metadata will contain an array of entries, like:
xxxxxxxxxx
[{
"entry": "reports-bundle.js",
"name": "reports-plugin",
"weight": 15,
"permissions": [
"admin"
],
"title": "Reports"
}, {
"entry": “home-addons-bundle.js",
"name": “home-addons-plugin"
}]
If you don't need customization with overrides, you might consider having enhancements, where you want to display extra widgets alongside or give that possibility to customers. Then, you can mark all components and provide them with an API, so that any place in your application can be customized and not be a part of a core product.
I'm looking forward to hearing your comments. To make it more clear, I’m going to create a sandbox application with minimal possible configuration to show the cases described in this article and demonstrate more complicated scenarios.
I will use Node.js, React, Redux, and Webpack. You can achieve the same with other tools, and I’ll be curious to see how you can do it and encourage you to reach me with your ideas and solutions. Thank you for your attention!
Further Reading
Opinions expressed by DZone contributors are their own.
Comments