2020-10-13: Updated to use webpack 5
Important: This first part of the article series shows Module Federation with a simple "TypeScript-only example". If you look for an example also using Angular, please directly jump to the 2nd part of this series.
The Module Federation integrated in Webpack beginning with version 5 allows the loading of separately compiled program parts. Hence, it finally provides an official solution for the implementation of microfrontends.
Until now, when implementing microfrontends, you had to dig a little into the bag of tricks. One reason is surely that current build tools and frameworks do not know this concept. Module Federation initiates a change of course here.
It allows an approach called Module Federation for referencing program parts that are not yet known at compile time. These can be self-compiled microfrontends. In addition, the individual program parts can share libraries with each other, so that the individual bundles do not contain any duplicates.
In this article, I will show how to use Module Federation using a simple example. The source code can be found here.
Example
The example used here consists of a shell, which is able to load individual, separately provided microfrontends if required:
The shell is represented here by the black navigation bar. The micro front end through the framed area shown below. Also, the microfrontend can also be started without a shell
This is necessary to enable separate development and testing. It can also be advantageous for weaker clients, such as mobile devices, to only have to load the required program part.
Concepts of Module Federation
In the past, the implementation of scenarios like the one shown here was difficult, especially since tools like Webpack assume that the entire program code is available when compiling. Lazy loading is possible, but only from areas that were split off during compilation.
With microfrontend architectures, in particular, one would like to compile and provide the individual program parts separately. In addition, mutual referencing via the respective URL is necessary. Hence, constructs like this would be desirable:
import('http://other-microfrontend');
Since this is not possible for the reasons mentioned, one had to resort to approaches such as externals and manual script loading. Fortunately, this will change with the Federation module in Webpack 5.
The idea behind it is simple: A so-called host references a remote using a configured name. What this name refers to is not known at compile time:
This reference is only resolved at runtime by loading a so-called remote entry point. It is a minimal script that provides the actual external url for such a configured name.
Implementation of the Host
The host is a JavaScript application that loads a remote when needed. A dynamic import is used for this.
The following host loads the component mfe1/component
in this way -- mfe1
is the name of a configured remote and component
the name of a file (an EcmaScript module) it provides.
const rxjs = await import('rxjs');
const container = document.getElementById('container');
const flightsLink = document.getElementById('flights');
rxjs.fromEvent(flightsLink, 'click').subscribe(async _ => {
const module = await import('mfe1/component');
const elm = document.createElement(module.elementName);
[…]
container.appendChild(elm);
});
Webpack would normally take this reference into account when compiling and split off a separate bundle for it. To prevent this, the ModuleFederationPlugin
is used:
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
[…]
output: {
publicPath: "http://localhost:5000/",
uniqueName: 'shell',
[…]
},
plugins: [
new ModuleFederationPlugin({
name: "shell",
library: { type: "var", name: "shell" },
remoteType: "var",
remotes: {
mfe1: "mfe1"
},
shared: ["rxjs"]
})
]
With its help, the remote mfe1
(Microfrontend 1) is defined. The configuration shown here maps the internal application name mfe1
to the same official name. Webpack does not include any import
that now relates to mfe1
in the bundles generated at compile time.
Libraries that the host should share with the remotes are mentioned in the shared
array. In the case shown, this is rxjs
. This means that the entire application only needs to load this library once. Without this specification, rxjs
would end up in the bundles of the host as well as those of all remotes.
For this to work without problems, the host and remote must agree on a common version.
In addition to the settings for the ModuleFederationPlugin
, we also need to place some options in the output
section. The publicPath
defines the URL under which the application can later be found. This reveals where the individual bundles of the application but also their assets, e.g. pictures or styles, can be found.
The uniqueName
is used to represents the host or remote in the generated bundles. By default, webpack uses the name from package.json
for this. In order to avoid name conflicts when using Monorepos with several applications, it is recommended to set the uniqueName manually.
Loading Shared Libraries
For loading shared libraries, we must use dynamic imports:
const rxjs = await import('rxjs');
They are asynchronous and this gives webpack the time necessary to decide upon which version to use and to load it. This is especially important in cases where different remotes and hosts use different versions of the same library. In general, webpack tries to load the highest compatible version. More about negotiating versions and dealing with version mismatches this can be found in a later article of this series.
To bypass this issue, it's a good idea to load the whole application with dynamic imports in the entry point used. For instance, the Micro Frontend could use a
which looks like this:main.ts
import('./component');
This gives webpack the time needed for the negotiation and loading the shared libraries when the application starts. Hence, in the rest of the application one can always use static ("traditional") imports like
import * as rxjs from 'rxjs';
Implementation of the Remote
The remote is also a standalone application. In the case considered here, it is based on Web Components:
class Microfrontend1 extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
async connectedCallback() {
this.shadowRoot.innerHTML = […]
;
}
}
const elementName = 'microfrontend-one';
customElements.define(elementName, Microfrontend1);
export { elementName };
Instead of web components, any JavaScript constructs or components based on frameworks can also be used. In this case, the frameworks can be shared between the remotes and the host as shown.
The webpack configuration of the remote, which also uses the ` ModuleFederationPlugin '', exports this component with the property exposes under the name component:
output: {
publicPath: "http://localhost:3000/",
uniqueName: 'mfe1',
[…]
},
[…]
plugins: [
new ModuleFederationPlugin({
name: "mfe1",
library: { type: "var", name: "mfe1" },
filename: "remoteEntry.js",
exposes: {
'./component': "./mfe1/component"
},
shared: ["rxjs"]
})
]
The name component refers to the corresponding file. In addition, this configuration defines the name mfe1
for the remote. To access the remote, the host uses a path that consists of the two configured names, mfe1
and component
. This results in the instruction shown above:
import('mfe1/component')
However, the host must know the URL at which it finds mfe1
. The next section shows how this can be accomplished.
Connect Host to Remote
To give the host the option to resolve the name mfe1
, the host must load a remote entry point. This is a script that the ModuleFederationPlugin
generates when the remote is compiled.
The name of this script can be found in the filename
property shown in the previous section. The url of the microfrontend is taken from the publicPath
property. This means that the url of the remote must already be known when it is compiled. Fortunately, there is already a PR which removed this need.
Now this script is only to be integrated into the host:
<script src="http://localhost:3000/remoteEntry.js"></script>
At runtime it can now be observed that the instruction
import('mfe1/component');
causes the host to load the remote from its own url (which is localhost:3000
in our case):
Conclusion and Outlook
The Module Federation integrated in Webpack beginning with version 5 fills a large gap for microfrentends. Finally, separately compiled and provided program parts can be reloaded and already loaded libraries can be reused.
However, the teams involved in developing such applications must manually ensure that the individual parts interact. This also means that you have to define and comply with contracts between the individual microfrontends, but also that you have to agree on a version for each of the shared libraries.
What's next? More on Architecture!
So far, we've seen that Module Federation is a strightforward solution for creating Micro Frontends on top of Angular. However, when dealing with it, several additional questions come in mind:
- According to which criteria can we sub-divide a huge application into micro frontends?
- Which access restrictions make sense?
- Which proven patterns should we use?
- How can we avoid pitfalls when working with Module Federation?
- Which advanced scenarios are possible?
Our free eBook (about 100 pages) covers all these questions and more:
Feel free to download it here now!