The Microfrontend Revolution – Part 2: Module Federation with Angular

With Module Federation we can use Angular and its router to lazy load separately compiled and deployed microfrontends.


This article is part of a series:


 

2020-10-13: Updated to use webpack 5 and Angular CLI 11.0.0-next.6.

Big thanks to Zack Jackson, the mastermind behind Module Federation, who helped me bypassing some pitfalls.

In my previous article, I've shown how to use Module Federation which is part of webpack beginning with version 5 to implement microfrontends. This article brings Angular into play and shows how to create an Angular-based microfrontend shell using the router to lazy load a separately compiled, and deployed microfrontend.

Besides using Angular, the result looks similar as in the previous article:

Shell

The loaded microfrontend is shown within the red dashed border. Also, the microfrontend can be used without the shell:

Microfrontend without Shell

The source code of the used example can be found in my GitHub account.

Getting started

To get started, we need an Angular CLI version supporting webpack 5. As it looks like, Angular CLI 11 which is due in fall 2020 will at least support webpack 5 as an opt-in. When writing this, there was already a beta version (v11.0.0-next.6) allowing to try everything out.

For opting-in, add this segment to your package.json, e. g. in front of the dependency section:

"resolutions": {
  "webpack": "5.0.0"
},

Then, install your dependencies again using yarn (!). Using yarn instead of npm is vital because it uses the shown resolutions section to force all installed dependencies like the CLI into using webpack 5.

To make the CLI use yarn by default when calling commands like ng add or ng update, you can use the following command:

ng config cli.packageManager yarn

Please note that the CLI version v11.0.0-next.6 does currently not support recompilation in dev mode when using webpack 5. Hence, you need to restart the dev server after changes. This issue will be solved with one of the upcoming beta versions of CLI 11.

Activating Module Federation for Angular Projects

The case study presented here assumes that both, the shell and the microfrontend are projects in the same Angular workspace. For getting started, we need to tell the CLI to use module federation when building them. However, as the CLI shields webpack from us, we need a custom builder.

The package @angular-architects/module-federation provides such a custom builder. To get started, you can just "ng add" it to your projects:

ng add @angular-architects/module-federation --project shell --port 5000
ng add @angular-architects/module-federation --project mfe1 --port 3000

While it's obvious that the project shell contains the code for the shell, mfe1 stands for Micro Frontend 1.

The command shown does several things:

  • Generating the skeleton of an webpack.config.js for using module federation
  • Installing a custom builder making webpack within the CLI use the generated webpack.config.js.
  • Assigning a new port for ng serve so that several projects can be served simultaneously.

Please note that the webpack.config.js is only a partial webpack configuration. It only contains stuff to control module federation. The rest is generated by the CLI as usual.

The Shell (aka Host)

Let's start with the shell which would also be called the host in module federation. It uses the router to lazy load a FlightModule:

export const APP_ROUTES: Routes = [
    {
      path: '',
      component: HomeComponent,
      pathMatch: 'full'
    },
    {
      path: 'flights',
      loadChildren: () => import('mfe1/Module').then(m => m.FlightsModule)
    },
];

However, the path mfe1/Module which is imported here, does not exist within the shell. It's just a virtual path pointing to another project.

To ease the TypeScript compiler, we need a typing for it:

// decl.d.ts
declare module 'mfe1/Module';

Also, we need to tell webpack that all paths starting with mfe1 are pointing to an other project. This can be done by using the ModuleFederationPlugin in the generated webpack.config.js:

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  output: {
    publicPath: "http://localhost:5000/",
    uniqueName: "shell"
  },
  optimization: {
    // Only needed to bypass a temporary bug
    runtimeChunk: false
  },
  plugins: [
    new ModuleFederationPlugin({
        remotes: {
            'mfe1': "mfe1@http://localhost:3000/remoteEntry.js" 
        },
        shared: ["@angular/core", "@angular/common", "@angular/router"]
    })
  ],
};

The remotes section maps the internal name mfe1 to the same one defined within the separately compiled microfrontend. It also points to the path where the remote can be found -- or to be more precise: to its remote entry. This is a tiny file generated by webpack when building the remote. Webpack loads it at runtime to get all the information needed for interacting with the microfrontend.

While specifying the remote entry's URL that way is convenient for development, we need a more dynamic approach for production. Fortunately, there are several options for doing this. One option is presented in a below sections.

The property shared contains the names of libraries our shell shares with the microfrontend.

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 be found later. This reveals where the individual bundles of the application and their assets, e.g. pictures or styles, can be found.

The uniqueName is used to represent 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.

The Microfrontend (aka Remote)

The microfrontend -- also referred to as a remote with terms of module federation -- looks like an ordinary Angular application. It has routes defined within in the AppModule:

export const APP_ROUTES: Routes = [
    { path: '', component: HomeComponent, pathMatch: 'full'}
];

Also, there is a FlightsModule:

@NgModule({
  imports: [
    CommonModule,
    RouterModule.forChild(FLIGHTS_ROUTES)
  ],
  declarations: [
    FlightsSearchComponent
  ]
})
export class FlightsModule { }

This module has some routes of its own:

export const FLIGHTS_ROUTES: Routes = [
    {
      path: 'flights-search',
      component: FlightsSearchComponent
    }
];

In order to make it possible to load the FlightsModule into the shell, we also need to reference the ModuleFederationPlugin in the remote's webpack configuration:

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

module.exports = {
  output: {
    publicPath: "http://localhost:3000/",
    uniqueName: "mfe1"
  },
  optimization: {
    // Only needed to bypass a temporary bug
    runtimeChunk: false
  },
  plugins: [
    new ModuleFederationPlugin({

        // For remotes (please adjust)
        name: "mfe1",
        library: { type: "var", name: "mfe1" },
        filename: "remoteEntry.js",
        exposes: {
            './Module': './projects/mfe1/src/app/flights/flights.module.ts',
        },        

        shared: ["@angular/core", "@angular/common", "@angular/router"]
    })
  ],
};

The configuration shown here exposes the FlightsModule under the public name Module. The section shared points to the libraries shared with the shell.

Standalone-Mode for Microfrontend

For microfrontends that also can be executed without the shell, we need to take care about one tiny thing: Projects configured with the ModuleFederationPlugin need to load shared libraries using dynamic imports!.

The reason is that these imports are asynchronous and so the infrastructure has some time to decide upon which version of shared libraries to use. This is especially important when the shell and the micro frontend provide different versions of the libraries shared. As I describe in a further part of this series, by default, webpack tries to load the highest compatible version. If there is not such a thing as "the highest compatible version", Module Federation provides several fallbacks. They are also described in the article mentioned.

So that developers are not constantly confronted with this limitation, it is advisable to load the entire application via a dynamic import instead. The entry point of the application -- in an Angular CLI project this is usually the main.ts file -- thus only consists of a single dynamic import:

import('./bootstrap');

This loads another TypeScript module called bootstrap.ts, which takes care of bootstrapping the application:

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { enableProdMode } from '@angular/core';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

As you see here, the bootstrap.ts file contains the very code normally found in main.ts.

Trying it out

To try everything out, we just need to start the shell and the microfrontend:

ng serve shell -o
ng serve mfe1 -o

Then, when clicking on Flights in the shell, the micro frontend is loaded:

Connecting the Shell and the Microfrontend

Hint: To start several projects with one command, you can use the npm package concurrently.

Bonus: Loading the Remote Entry

As discussed above, the microfrontend's remote entry can be defined in the shell's webpack configuration. However, this demands us to foresee the microfrontend's URL when compiling the shell.

As an alternative, we can also load the remote entry by referencing it with a script tag:

<script src="http://localhost:3000/remoteEntry.js"></script>

This script tag can be dynamically created, e. g. by using server side templates or by manipulating the DOM on the client side.

To make this work, we need to switch the remoteType in the shell's config to var:

new ModuleFederationPlugin({
    remoteType: 'var',
    [...]
})

There are even more dynamic ways allowing you to inform the shell just at runtime how many microfrontends to respect, what's their names and where to find them. A further article of this series describes this approach known as Dynamic Module Federation.

Conclusion and Evaluation

The implementation of microfrontends has so far involved numerous tricks and workarounds. Webpack Module Federation finally provides a simple and solid solution for this. To improve performance, libraries can be shared and strategies for dealing with incompatible versions can be configured.

It is also interesting that the microfrontends are loaded by Webpack under the hood. There is no trace of this in the source code of the host or the remote. This simplifies the use of module federation and the resulting source code, which does not require additional microfrontend frameworks.

However, this approach also puts more responsibility on the developers. For example, you have to ensure that the components that are only loaded at runtime and that were not yet known when compiling also interact as desired.

One also has to deal with possible version conflicts. For example, it is likely that components that were compiled with completely different Angular versions will not work together at runtime. Such cases must be avoided with conventions or at least recognized as early as possible with integration tests.

Don't Miss Anything!


Subscribe to our newsletter to get all the information about Angular.


* By subscribing to our newsletter, you agree with our privacy policy.

Unsere Angular-Schulungen

No post was found with your current grid settings. You should verify if you have posts inside the current selected post type(s) and if the meta key filter is not too much restrictive.

Current Blog Articles

Only One Step Away!

Send us your inquery today - we help you with pleasure!

Jetzt anfragen!