Until now, the recommended setup by the single-spa core team has been based on importmaps in combination with SystemJS. This setup still works great today. But browser technology has evolved, and the single-spa team is currently working on renewing their recommended setup based on native ESM (without SystemJS).
Since v17, Angular provides an improved builder which is based on Vite (dev server) and esbuild (output bundling).
Since esbuild does not support SystemJS as an output format (and likely never will) it makes perfect sense for us to refactor our micro-frontend setup to align with the envisioned native-ESM approach which will be recommended by single-spa core team going forward.
Since we don't need to support Internet Explorer anymore, we can now confidently rely on <script type="importmap">
being natively supported by all modern browsers.
However, there are 3 features that we would like to incorporate in our architecture that are currently not supported by native importmap implementations:
- external importmaps: i.e.
<script type="importmap" src="...">
- multiple importmaps: native browser implementations support only 1 importmap
- importmap overrides: we want to enable developers to override certain modules on deployed environments, which creates a really nice dev experience.
To overcome these native importmap limitations, 2 possible solutions come to mind:
- es-module-shims can be used in
shimMode
to add all these features. The downside is that this introduces a very small (~5ms) performance hit. Even though this hit might be negligable, it would be nice if we could stick with pure native ES modules instead. - import-map-injector combines multiple
<script type="injector-importmap">
elements into 1 final<script type="importmap">
In this way, it adds support for both multiple and external importmaps.
By choosing for the import-map-injector
approach, we get all the features that we want, while staying as native to the browser as possible.
Finally, to enable override of importmap modules, we use import-map-overrides. This can work together with import-map-injector
just fine, but it's important to note that the import-map-override script must be loaded before import-map-injector (which makes sense of course because the injector script will only run once and it assumes that all <script type="injector-importmap">
elements are present in the DOM before it runs).
While setting up this proof-of-concept repo, some additional challenges came up. We'll go over them here, so that you can understand the reasoning behind certain choices that were made here.
During development, we want to see our code changes reflected in the browser as quickly as possible. Vite usually takes care of that for us out-of-the-box, when we are working on a "normal" application. The way Vite does that, is by auto-injecting a <script type="module" src="@vite/client">
at the top of the app's index.html file. This @vite/client
script will then set up the websockets communication with the Vite dev server and it will reload the page when the Vite dev server notifies the client that a change was made.
However, things are a bit different when working with this MF (micro-frontend) architecture. More specifically, when working in this way each MF is an app that will get loaded by dynamically importing the micro-frontend's entry javascript module file. This means that we are not using any index.html file that belongs to specific MF apps (and therefore no out-of-the-box websocket communication with the MF dev-server).
Fortunately, we can very easily import the @vite/client
module ourselves, from each module's main JS file, during development. We only need to know the origin
url for each MF module, and thanks to import.meta we have that information available at our fingertips. This works flawlessly, and we have made a tiny helper function loadViteClient
that we use within this repo.
Any static assets, like images, that we use in our micro-frontends must be referred to using absolute urls. Relative urls will not work, because these assets are relative to the MF module. Not relative to the app-shell page.
For example: consider the app-shell page running on URL http://localhost:4300. This app-shell now loads a micro-frontend which can be served from a different url like http://localhost:4201/main.js. In case this micro-frontend would refer to a static asset in a relative way, like <img src="/assets/cat.jpg">
then the browser would resolve the image url to http://localhost:4300/assets/cat.jpg and it would result in a 404 because the image is really located at http://localhost:4201/assets/cat.jpg.
So how can we refer to assets using absolute urls, without the need to hard-code the full final url? That's right: import.meta comes to rescue us again here. In the same way that we used it to load the @vite/client
, we can also rely on it to correctly resolve the full static asset urls.
When we have a global stylesheet (e.g. styles.css
) that we want to use with our Angular micro-frontend apps, another problem arises. Normally, we would reference these global stylesheets from the angular.json
file (or project.json
in case of Nx). For example:
"targets": {
"build": {
"options": {
"styles": [
"apps/cats/src/styles.css"
],
}
}
}
However, when using the angular application builder this will result in a separate styles.css
file which Angular expects us to reference from a <link rel="stylesheet">
tag in the index.html file. But that is not what we want. We would prefer these styles to be included in our main JS bundle and get injected into the DOM automatically when we import()
our micro-frontend module at runtime.
To work around this problem, we could take a slightly different approach. First, we remove the global stylesheet(s) again from our project.json file:
"targets": {
"build": {
"options": {
"styles": [
- "apps/cats/src/styles.css"
],
}
}
}
Then, we can create a small wrapper component at the top level of our app as follows:
import { Component, ViewEncapsulation } from '@angular/core';
import { AppComponent } from './app.component';
@Component({
standalone: true,
imports: [AppComponent],
selector: 'app-root',
template: `<app-main />`,
styleUrls: ['../styles.css'],
encapsulation: ViewEncapsulation.None,
})
export class AppRootComponent {}
Finally, we can use this wrapper component to bootstrap our app:
bootstrapApplication(AppRootComponent, appConfig)
Note that we referenced our global stylesheet from this wrapper component, in combination with ViewEncapsulation.None
. This will cause Angular to do exactly what we want: injecting the global styles into the DOM when we are bootstrapping our micro-frontend app at runtime.
By piecing together all of the above, we can achieve an architecture that is ideal to handle our needs:
- Angular micro-frontend apps, served/bundled as pure ES Modules by Vite/esbuild
- Native browser importmap support, enhanced by import-map-injector
- Route-based loading of micro-frontends handled by single-spa
- live-reload functionality handled by manually importing
@vite/client
from our MF modules at runtime (only during development)