Building Micro frontends with single-spa (Series 2)

Fan
carsales-dev
Published in
5 min readDec 3, 2020

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 and MSAL 2.0) are bundled at build time and loaded as part of the static apps (Template Management and Auth).
  • Shared dependencies (vue, vuex and vue-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 and vuex 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.

--

--

Fan
carsales-dev

A Software Engineer & A Weekend Woodworker