Building Micro frontends with single-spa (Series 2)
Dependency resolution via SystemJS
In our previous article (Building Micro frontends with Single-SPA (Series 1) Get started with Single-SPA), we have covered the way applications are managed in single-spa. This time, we will look at how applications share dependencies, such that single-spa understands and loads them correctly.
SystemJS is one of the single-spa recommended dynamic module loaders. It provides out-of-the-box functionality in terms of module registration, mapping, scoping, resolving, integrity check etc. Webpack (from 4.30.0) applications can be configured to build into SystemJS modules by setting libraryTarget
to system
. This makes modern Webpack applications able to utilize SystemJS for module loading.
In our experience, we found it easy to configure, developer friendly and integrates well with single-spa.
1. Our case
Our internal web application Admin Portal is a project owning multiple applications that serve different department requirements. The dependencies are not only shared but also flexible and lazy-loaded. A simple dependency map looks like this:
The requirements are as follows:
- Non-shared dependencies (NPM packages
timeago
andMSAL 2.0
) are bundled at build time and loaded as part of the static apps (Template Management and Auth). - Shared dependencies (
vue
,vuex
andvue-router
) are excluded from the build bundle (using webpack externals) and resolved by SystemJS, dynamically loaded from JSDelivr upon request, at browser run-time. - More precisely,
vue
andvuex
will not be downloaded unless single-spa requests for navbar and template management. Similarly,vue-router
will not be downloaded unless single-spa requests for template management.
2. SystemJS Import Map
Import map is a feature to dynamically import modules by “bare specifiers”, and resolve at run time. This gives web developers great experience to reduce bundling size, as well as a unified location to manage the JavaScript modules. However, because it is only implemented in Chrome, SystemJS, as a pollyfill was a great alternative.
In our case, the MSAL 2.0
package and timeago
are bundled by Webpack at build time, while others are in importmap:
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa/lib/system/single-spa.min.js",
"vue": "https://cdn.jsdelivr.net/npm/vue,
"vue-router": "https://cdn.jsdelivr.net/npm/vue-router/dist/vue-router.min.js",
"vuex": "https://cdn.jsdelivr.net/npm/vuex/dist/vuex.min.js",
"navbar": "https://{{S3}}/navbar/app.js",
"template-management": "https://{{S3}}/template-management/app.js",
"auth": "https://{{S3}}/auth/index.js",
"root": "/root-config.js"
}
}
</script>
(Root-config package is required by single-spa, it’s the entry of the whole application.)
SystemJS cannot be specified in importmap, so here is our code:
<! — sequence matters here -->
<script src="https://cdn.jsdelivr.net/npm/systemjs/dist/system.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs/dist/extras/amd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/systemjs/dist/extras/named-exports.min.js"></script>
After that, SystemJS takes care of the dependencies for you! You can use System.import('template-management')
for single-spa to load the Template Management application, or use import vue from 'vue'
like any ES modules. Meanwhile, vue
, vuex
and vue-router
are loaded on-demand, as per our requirements in section 1.
A similar sample code can be found at their official example: https://github.com/vue-microfrontends/root-config
3. Import Map Overrides — an integrated developer experience
Now comes the real fun.
Since SystemJS loads modules at run time, we can easily override it, so it always points to our local dev environment. As a developer, you won’t have to bother running all the supporting apps; rather, you only need to run one application — the one that you are working on — while integrating it into other environments, say, staging.
To give you a better idea, this is an example that we develop blocklist in staging environment:
Its GitHub page gives a substantial instruction so we will skip the code part here.
4. Import map deployer
Browser side caching is a recommended approach to reduce the number of requests fetching JavaScript bundles. A common approach is to use hashed file names (you can do this easily in Webpack).
The problem arises when we use import map. As demonstrated above (in Section 2), the bundle locations are hardcoded in HTML:
"template-management": "https://{{S3}}/template-management/app.js", "auth": "https://{{S3}}/auth/index.js",
Updating hardcoded dom against dynamically hashed file names will be the last thing a developer wants to do.
Single-spa recommends import map deployer, with two benefits:
- It acknowledges browsers about the latest JavaScript bundle location
- It avoids race condition when multiple entities update the same import map.
When taking a closer look at the deployer, it is nothing but a Node Express API that publishes latest build results to a cloud storage. We decided to create our own ASP.NET Core API. It exposes two endpoints:
- PATCH for build pipelines to update their JavaScript bundle location with hash name. (The bash command pictured below.)
curl --request PATCH -H "Content-Type:application/json" \
https://{{import-map-deployer}}/importmap \
--data "{ \
\"bareSpecifier\": \"template-management\", \
\"jsEntry\": \"https://{{S3}}/app.[hashname].js\" \
}"
- GET for browsers to fetch the locations, which returns a subset of the previous import map with hashed names:
{
"imports": {
"navbar": "https://{{S3}}/navbar/app.[hashname].js",
"template-management": "https://{{S3}}/template-management/app.[hashname].js",
"auth": "https://{{S3}}/auth/index.[hashname].js",
}
}
A finalized import map in index.html may look like this:
<script type="systemjs-importmap">
{
"imports": {
"single-spa": "https://cdn.jsdelivr.net/npm/single-spa/lib/system/single-spa.min.js",
"vue": "https://cdn.jsdelivr.net/npm/vue",
"vue-router": "https://cdn.jsdelivr.net/npm/vue-router/dist/vue-router.min.js",
"vuex": "https://cdn.jsdelivr.net/npm/vuex/dist/vuex.min.js",
"root": "/root-config.js"
}
}
</script>
<script type="systemjs-importmap" src="https://{{import-map-deployer}}/importmap"></script>
The https://{{import-map-deployer}}/importmap
endpoint returns the content from the GET API
The next thing is to setup the server to return Cache-Control header so that the browser caches the JavaScript files on the first hit. It then loads from disk memory on the following requests. In the meantime, the endpoint https://{{import-map-deployer}}/importmap
always returns the latest JavaScript file name, browsers will always fetch the latest JavaScript bundle whenever there is an update. Easy!
Conclusion
SystemJS is a dynamic dependency resolving package. It forms a great team with single-spa developing a micro front-end architecture. However, it is not necessary to use single-spa together with SystemJS and developers can choose either one of them to suits their needs.
With the help of SystemJS, we can easily integrate multiple applications and dependencies whilst simultaneously improving the developer experience.