
Dynamic Module Federation with Angular (The Microfrontend Revolution, Part 3)
Loading microfrontends not known at compile time.
This article is part of a series:
- Part 1: Module Federation in Webpack 5
- Part 2: Module Federation with Angular
- Part 3: Dynamic Module Federation with Angular (this one)
- Part 4: Building A Plugin-based Workflow Designer With Angular and Module Federation
- Part 5: Getting Out of Version-Mismatch-Hell with Module Federation
- Part 6: Using Module Federation with Nx Monorepos and Angular
- Part 7: Multi-Framework and -Version Micro Frontends with Module Federation: The Good, the Bad, the Ugly
In the previous article of this series, I've shown how to use Webpack Module Federation for loading separately compiled microfrontends into a shell. As the shell's webpack configuration describes the microfrontends, we already needed to know them when compiling it.
In this article, I'm assuming a more dynamic situation where the shell does not know the microfrontends or even their number upfront. Instead, this information is provided at runtime via a lookup service.
The following image displays this idea:
For all microfrontends the shell gets informed about at runtime it displays a menu item. When clicking it, the microfrontend is loaded and displayed by the shell's router.
As usual, the source code used here can be found in my GitHub account.
Module Federation Config
Let's start with the shell's Module Federation configuration. In this scenario, it's as simple as this:
new ModuleFederationPlugin({
remotes: {},
shared: {
"@angular/core": { singleton: true, strictVersion: true },
"@angular/common": { singleton: true, strictVersion: true },
"@angular/router": { singleton: true, strictVersion: true }
}
}),
We don't define any remotes (microfrontends) upfront but configure the packages we want to share with the remotes we get informed about at runtime.
As mentioned in the last article of this series, the combination of singleton: true
and strictVersion: true
makes webpack emit a runtime error when the shell and the micro frontend(s) need different incompetible versions (e. g. two different major versions). If we skipped strictVersion
or set it to false
, webpack would only emit a warning at runtime.
The configuration of the microfrontends, however, looks like in the previous article:
new ModuleFederationPlugin({
name: "mfe1",
filename: "remoteEntry.js",
exposes: {
'./Module': './projects/mfe1/src/app/flights/flights.module.ts'
},
shared: {
"@angular/core": { singleton: true, strictVersion: true },
"@angular/common": { singleton: true, strictVersion: true },
"@angular/router": { singleton: true, strictVersion: true },
[...]
}
}),
Routing to Dynamic Microfrontends
To dynamically load a microfrontend at runtime, we can use the helper function loadRemoteModule provided by the @angular-architects/module-federation
plugin:
import { loadRemoteModule } from '@angular-architects/module-federation';
[...]
const routes: Routes = [
[...]
{
path: 'flights',
loadChildren: () =>
loadRemoteModule({
remoteEntry: 'http://localhost:3000/remoteEntry.js',
remoteName: 'mfe1',
exposedModule: './Module'
})
.then(m => m.FlightsModule)
},
[...]
];
As you might have noticed, we're just switching out the dynamic import
normally used here by a call to loadRemoteModule
which also works with key data not known at compile time. The latter one uses the webpack runtime api to get hold of the remote on demand.
Improvement for Dynamic Module Federation
This was quite easy, wasn't it? However, we can improve this solution a bit. Ideally, we load the remote entry upfront before Angular bootstraps. In this early phase, Module Federation tries to determine the highest compatible versions of all dependencies.
Let's assume, the shell provides version 1.0.0 of a dependency (specifying ^1.0.0 in its package.json) and the micro frontend uses version 1.1.0 (specifying ^1.1.0 in its package.json). In this case, they would go with version 1.1.0. However, this is only possible if the remote's entry is loaded upfront. More details about how Module Federation deals with different versions can be found in this article.
To achieve this goal, let's use the helper function loadRemoteEntry
in our main.ts
import { loadRemoteEntry } from '@angular-architects/module-federation';
Promise.all([
loadRemoteEntry('http://localhost:3000/remoteEntry.js', 'mfe1')
])
.catch(err => console.error('Error loading remote entries', err))
.then(() => import('./bootstrap'))
.catch(err => console.error(err));
Here, we need to remember, that the @angular-architects/module-federation
plugin moves the contents of the original main.ts
into the bootstrap.ts
file. Also, it loads the bootstrap.ts
with a dynamic import in the main.ts
. This is necessary because the dynamic import gives Module Federation the needed time to negotiate the verions of the shared libraries to use with all the remotes.
Also, loading the remote entry needs to happen before importing bootstrap.ts
so that its metadata can be respected during the negotiation.
After this, we don't need to pass the remote entry's url to loadRemoteModule when we lazy load the micro frontend with the router:
import { loadRemoteModule } from '@angular-architects/module-federation';
[...]
const routes: Routes = [
[...]
{
path: 'flights',
loadChildren: () =>
loadRemoteModule({
// We don't need this anymore b/c its loaded upfront now
// remoteEntry: 'http://localhost:3000/remoteEntry.js',
remoteName: 'mfe1',
exposedModule: './Module'
})
.then(m => m.FlightsModule)
},
[...]
]
However, we could also stick with it, because loadRemoteModule
remembers what was loaded and never loads a thing twice.
Bonus: Dynamic Routes for Dynamic Microfrontends
There might be situations where you don't even know the number of microfrontends upfront. Hence, we also need an approach for setting up the routes dynamically.
For this, I've defined a Microfrontend
type holding all the key data for the routes:
export type Microfrontend = LoadRemoteModuleOptions & {
displayName: string;
routePath: string;
ngModuleName: string;
}
LoadRemoteModuleOptions
is the type that's passed to the above-discussed loadRemoteModule
function. The type Microfrontend
adds some properties we need for the dynamic routes and the hyperlinks pointing to them:
displayName: Name that should be displayed within the hyperlink leading to route in question.
routePath: Path used for the route.
ngModuleName: Name of the Angular Module exposed by the remote.
For loading this key data, I'm using a LookupService
:
@Injectable({ providedIn: 'root' })
export class LookupService {
lookup(): Promise<Microfrontend[]> {
[...]
}
}
After receiving the Microfrontend
array from the LookupService
, we can build our dynamic routes:
export function buildRoutes(options: Microfrontend[]): Routes {
const lazyRoutes: Routes = options.map(o => ({
path: o.routePath,
loadChildren: () => loadRemoteModule(o).then(m => m[o.ngModuleName])
}));
return [...APP_ROUTES, ...lazyRoutes];
}
This function creates one route per array entry and combines it with the static routes in APP_ROUTES
.
Everything is put together in the shell's AppComponent
. It's ngOnInit
method fetches the key data, builds routes for it, and resets the Router's configuration with them:
@Component({ [...] })
export class AppComponent implements OnInit {
microfrontends: Microfrontend[] = [];
constructor(
private router: Router,
private lookupService: LookupService) {
}
async ngOnInit(): Promise<void> {
this.microfrontends = await this.lookupService.lookup();
const routes = buildRoutes(this.microfrontends);
this.router.resetConfig(routes);
}
}
Besides this, the AppComponent
is also rendering a link for each route:
<li *ngFor="let mfe of microfrontends">
<a [routerLink]="mfe.routePath"></a>
</li>
Conclusion
Dynamic Module Federation provides more flexibility as it allows loading microfrontends we don't have to know at compile time. We don't even have to know their number upfront. This is possible because of the runtime API provided by webpack. To make using it a bit easier, the @angular-architects/module-federation
plugin wrap it nicely into some convenience functions.
Unsere Angular-Schulungen
