The first part of this article series showed how to use modern Angular with Native Federation and esbuild. We've assumed that all Micro Frontends and the shell use the same framework in the same version. However, if the goal is to integrate Micro Frontends that are based on different frameworks and/or framework versions, you need some additional considerations.
As discussed in this blog article, such an approach is nothing you normally want to introduce without a good reason like dealing with legacy systems or combining existing products to a suite.
📂 Source Code
(see branch nf-web-comp-mixed
)
Abstracting Micro Frontends with Web Components
The first step when implementing such a scenario is abstracting the different frameworks and versions. A popular way for this is to use Web Components that encapsulate entire Micro Frontends. The result is not a ideal-typical Web Components in the sense of reusable widgets, but rather a coarse-grained web components that represent entire domains. The following image shows a Web Component bootstrapping a React application that is loaded into an Angular-based shell:
Actually, it's not difficult to write a Web Component that delegates to a framework instead of writing something into the DOM itself. Angular makes even this task easier with the @angular/elements converting an Angular component into a Web Component. Technically speaking, it wraps the Angular Component into a Web Component created on the fly.
The package @angular/elements can be installed with npm (npm i @angular/elements
). Together with Standalone Components, it's used in a straight forward manner:
import { NgZone } from '@angular/core';
(async () => {
const app = await createApplication({
providers: […],
});
const mfe2Root = createCustomElement(AppComponent, {
injector: app.injector,
});
customElements.define('mfe2-root', mfe2Root);
})();
The lines of code shown here replace bootstrapping the application. The createApplication
function creates an Angular application including a root injector. The latter is configured via the providers array. However, instead of bootstrapping a component, createCustomElement
transforms a standalone component into a web component.
The customElements.define
method is already part of the browser's API and registers the Web Component under the name mfe2-root
. This means that from now on the browser will render the web component and thus the Angular component behind it as soon as <mfe2-root></mfe2-root>
appears in the markup. When assigning the name, please note that by definition it must contain a hyphen. This is to avoid naming conflicts with existing HTML elements.
In order to share this Web Component via Native Federation, the file defining the Web Component must be specified in the federation.config.js
under exposes
. In the case considered here, this is bootstrap.ts
:
exposes: {
'./web-components': './projects/mfe2/src/bootstrap.ts',
},
This approach gives us the best of both worlds: Using Native Federation, frameworks and libraries can be shared as long as multiple Micro Frontends use the same version. By abstracting differences via Web Components, different frameworks and framework versions can also be integrated:
Loading Web Components in a Shell
Providing a Web Component via Native Federation is only one side of the coin: We also have to load the Web Component into a shell. Since the Angular Router can only work with Angular Components, it makes sense to wrap our Web Components into an Angular Component:
import { loadRemoteModule } from '@softarc/native-federation-runtime';
@Component({
selector: 'app-wrapper',
standalone: true,
imports: [CommonModule],
templateUrl: './wrapper.component.html',
styleUrls: ['./wrapper.component.css']
})
export class WrapperComponent implements OnInit {
elm = inject(ElementRef);
async ngOnInit() {
await loadRemoteModule('mfe2', './web-components');
const root = document.createElement('mfe2-root');
this.elm.nativeElement.appendChild(root);
}
}
This WrapperComponent
loads the Web Component via Native Federation and creates an HTML element into which the browser renders the Web Component. To simplify things, the key data used for this -- the names mfe2
, ./web-components
and mfe2-root
-- are hard-coded in the example shown. In order to make the WrapperComponent
universally applicable, it is advisable to make this information parameterizable, e. g. via an @Input:
@Component([...])
export class WrapperComponent implements OnInit {
elm = inject(ElementRef);
@Input() config = initWrapperConfig;
async ngOnInit() {
const { exposedModule, remoteName, elementName } = this.config;
await loadRemoteModule(remoteName, exposedModule);
const root = document.createElement(elementName);
this.elm.nativeElement.appendChild(root);
}
}
The constant initWrapperConfig
and its underlying type is defined as follows:
export interface WrapperConfig {
remoteName: string;
exposedModule: string;
elementName: string;
}
export const initWrapperConfig: WrapperConfig = {
remoteName: '',
exposedModule: '',
elementName: '',
}
It's also noteworthy that since Angular 16, the router can directly bind routing parameters to an @Input. For this, activate the following feature during bootstrap:
bootstrapApplication(AppComponent, {
providers: [
provideRouter(
APP_ROUTES,
withComponentInputBinding()
)
],
});
This allows routes as the following:
export const APP_ROUTES: Routes = [
[...],
{
path: 'passengers',
component: WrapperComponent,
data: {
config: {
remoteName: 'mfe2',
exposedModule: './web-components',
elementName: 'mfe2-root',
} as WrapperConfig,
},
},
[...]´
];
Sharing Zone.js
Angular currently still uses Zone.js for change detection. The NgZone
service represents Zone.js
within Angular. To avoid problems, you should ensure that all Micro Frontends and the shell use the same NgZone
instance. To achieve this, the shell's AppComponent
could share its NgZone
across the global namespace:
@Component([…])
export class AppComponent {
constructor() {
globalThis.ngZone = inject(NgZone);
}
}
The individual Micro Frontends can then use this instance during bootstrapping:
const app = await createApplication({
providers: [
globalThis.ngZone ? { provide: NgZone, useValue: globalThis.ngZone } : [],
provideRouter(APP_ROUTES),
],
});
Fortunately, with Signals we are looking into a Zone-less future and once Angular works without Zone.js we can git rid of this workaround.
Web Components with own Routes
Things get a little more exciting when the micro frontend of the web component also uses routing. In this case, two routers duel over the URL - the shell's router and the Micro Frontend's router:
To ensure that the two routers do not interfere with each other, the following procedure has proven to be effective:
- Each route of the Micro Frontend is given a unique prefix.
- The shell tells its router to only pay attention to the first URL segment. Based on this segment, the shell loads a Micro Frontend, which makes its own routing decisions based on the remaining segments.
To specify which part of the Url is interesting for the respective router, you can use an UrlMatcher
:
[…]
import { loadRemoteModule } from '@angular-architects/native-federation';
import { WrapperComponent } from './wrapper/wrapper.component';
import { WrapperConfig } from './wrapper/wrapper-config';
import { startsWith } from './starts-with';
export const APP_ROUTES: Routes = [
[…]
{
matcher: startsWith('profile'),
component: WrapperComponent,
data: {
config: {
remoteName: 'mfe3',
exposedModule: './web-components',
elementName: 'mfe3-root',
} as WrapperConfig,
},
},
[…]
];
The Angular router usually decides for or against a route based on the configured paths. UrlMatchers
are an alternative to this. These are functions telling the router whether the configured route should be activated. The function startsWith
for instance checks whether the current URL starts with the passed segment.
In our example, the shell's router uses this matcher to check whether the current URL starts with profile
.
Workaround for Routers in Web Component
In order for the router to react to route changes in the web component, it needs a special invitation. The examples discussed here include an helper method connectRouter
that calls the Micro Frontend in its AppComponent
:
@Component({ … })
export class AppComponent implements OnInit {
constructor() {
connectRouter();
}
}
What's next? More on Architecture!
Please find more information on enterprise-scale Angular architectures in our free eBook (5th edition, 12 chapters):
- According to which criteria can we subdivide a huge application into sub-domains?
- How can we make sure, the solution is maintainable for years or even decades?
- Which options from Micro Frontends are provided by Module Federation?
Feel free to download it here now!
Conclusion
Combining several frameworks or framework versions is for sure not your first choice. However, if there is a good reason, you can achieve this goal by abstracting the different Micro Frontends. Using Web Components for this is a popular choice.
However, we should be aware that no one is officially testing whether a given framework can peacefully coexist in the same browser window with another framework or another version of it self. Also, we need some workarounds, e.g. for the router or for sharing one Zone.js instance. The latter one is already counted, as Signals will eventually allow to go Zone-less.
Another concern is increased bundle sizes. Lazy Loading different Micro Frontends with different frameworks or versions can help here. Also the next part of this series shows some approaches to improve performance in an Micro Frontend architecture.