DEV Community

Cover image for Using ES Modules with Dynamic Imports to Implement Microfrontends
Florian Rappl
Florian Rappl

Posted on • Originally published at blog.bitsrc.io

Using ES Modules with Dynamic Imports to Implement Microfrontends

Technologies always follow two main principles: An iterative evolution to leverage what was invented beforehand to come up with something better, and a reinvention of old concepts using the iterative advancements.

An example of this is cloud computing. The old concept is time-sharing of larger, shared resources among dedicated subscriptions. The advancements necessary have been the Internet, improved networking, and proper virtualization — especially containerization — of the underlying machine.

Modularizing the Frontend

One of the current topics is microfrontends. This follows the cycle of modularization and decomposition. While there was a more strict separation of systems in the past, over time monoliths and integrated systems have become the norm.

With recent advancements and improved development systems separations have become more efficient. First, backend and frontend have been split into different systems, then backends became more modular leveraging, for instance, microservice-oriented architectures. With microfrontends, we are now capable of doing the same on the frontend. But how?

Example: A micro frontends implementation using Bit (hover over the components to see their scope)

Patterns for Modularization

There are three main reasons why microfrontends are not yet more popular:

  1. The whole pattern and many available frameworks just have been introduced in the last 2–3 years.
  2. The app / domain / scenario just needs to fit. Usually, its anyway just a single dev team for a single purpose application not growing at a rapid race.
  3. There is not a single microfrontend architecture. Like with microservices there are many ways to achieve it. However, unlike microservices it is not directly clear what approach should be favored.

One of the reasons why microfrontends are different to microservices is that a frontend is still consumed as a single instance by the end-user. Microservices may also be aggregated in reverse proxies or API gateways, but never have to be consistent, e.g., in their resource layout or use of certain libraries or (UX) patterns.

I tend to see three fundamentally different ways of implementing microfrontends:

  • Compile-time rendered — a central CI/CD pipeline builds a monolith when any of its independent components update. For example, using Bit, components are built independently — then, published (from different repos) to a “collection” on Bit.dev. These published components will then be imported and integrated into a single app. Whenever a component gets modified and “pushed” with a bumped version, it triggers the CI/CD of the project that is composing everything.
  • Server-side rendered (i.e., a view is composed from different parts upon request, potentially also cache-able)
  • Runtime rendered (i.e., the page is constructed in the browser, the different parts may be combined dynamically)

While combinations (and variations) of these are possible (e.g., in Piral we use runtime rendered, but the other two modes are possible, too, if certain conditions are met) in the end the primary area of use is determined exclusively what the resulting application should do and where it should be delivered.

In terms of ease of implementation, the compile-time and run-time solutions certainly excel. If we are interested in flexibility then run-time rendering is appealing.

Quite often we don’t actually need some libraries or frameworks — we can just leverage standard technologies like ES Modules for introducing microfrontends.

ES Modules

ES Modules (abbreviated ESM) is the ECMAScript standard for working with modules. While for development we usually use synchronous modes like CommonJS (introduced with Node.js), ESMs allow both, composition at run-time and at compile-time.

Compared to standard scripts ESMs have the following differences:

  • Require type being set to module
  • Are always deferred, no need for defer or async
  • Definitely run only once — even if referenced multiple times explicitly
  • Properly use CORS with authentication
  • Can leverage ES6 import and export statements without transpilation to other mechanisms (e.g., require).

Most notably, all import paths are relative to the current ESM, however, we could still use strong names (something like package names, or aliases) by defining an import map.

Import Maps

The import map proposal is one of the cornerstones of ESM flexibility. Essentially, it allows defining where a package name should point to. An example would be the following JSON snippet:

{
  "imports": {
    "moment": "/moment/src/moment.js"
  }
}

Having defined moment in the import map would allow us to use import 'moment' without needing to state where Moment.js would be located. Now the only question is how to bring the import map to the browser. Well, it turns out all we need is another script tag:

<script type="importmap" src="map.json"></script>

Alternatively, we can also inline define the import map. The latter would be great to avoid the extra request necessary before ESMs could be evaluated.

The caveat is that the browser support for import maps is poor with Chrome being the only platform to be actively looking into implementing it right now.

Nevertheless, there is hope — in form of SystemJS.

SystemJS

The project is described as:

Configurable module loader, running System modules at almost-native speed, and enabling ES module semantics and features such as top-level await, dynamic import, and import maps with full compatibility in older browsers including IE.

In a nutshell, SystemJS gives us a way of using ESMs (or modules in general) without relying on specific browsers.

Using SystemJS can be as simple as just importing the SystemJS script from a CDN. If we want to customize the behavior then we can also take modules one by one.

For instance:

import "systemjs/dist/system";
import "systemjs/dist/extras/amd";
import "systemjs/dist/extras/named-exports";
import "systemjs/dist/extras/named-register";
import "systemjs/dist/extras/use-default";

This takes SystemJS and a couple of quite useful additions such as AMD modules with named exports, default exports, and referenced registrations.

Especially in combination with import maps, SystemJS is super useful. One example is the import-map-overrides package, which allows us to define overrides for desired locations within our app. This way, we could easily swap packages during development - even on live web apps.

Using the import-map-overrides package is as straight forward as importing it before any SystemJS package:

import "import-map-overrides/dist/import-map-overrides";

But let’s jump back to the topic of the post...

ESMs for Microfrontends

Essentially, the idea is that we can have a file like

<!doctype html>
<script type="module" src="./microfrontend1.js"></script>
<script type="module" src="./microfrontend2.js"></script>
<script type="module" src="./microfrontend3.js"></script>

and everything would just work as intended. In reality, obviously, we would need a couple more things such as the scripts for SystemJS. Furthermore, some kind of bootstrapping mechanism to actually orchestrate the microfrontends would be useful.

We end up with the following:

<!doctype html>
<script type="systemjs-importmap" src="./dependencies.json"></script>
<script type="systemjs-importmap">
{
  "imports": {
    "mfe1": "./microfrontend1/index.js",
    "mfe2": "./microfrontend2/index.js",
    "mfe3": "./microfrontend3/index.js"
  }
}
</script>
<script src="./shell.js"></script>
<script>
System.import("mfe1");
System.import("mfe2");
System.import("mfe3");
</script>

We used shell.js as a placeholder for our app shell script, which could be as simple as using SystemJS with the desired extras.

In the form outlined above, each microfrontend would need to be able to bootstrap itself. Consequently, each microfrontend has the logic to detect when it should be running, where it should be running, and how it interacts with all the other microfrontends (visually and from the behavior/information sharing perspective).

If we would want to avoid the lax interpretation and provide more boundaries for the microfrontends we could, for instance, come up with some API to be used.

window.registerMicrofrontend = (definition) => {
  // ...
};

As such, each microfrontend could just call registerMicrofrontend to register itself (incl. potentially shared components, functions, data, ...).

Alternatively, using the quite explicit approach above we could also export the definition object and use it after the import resolved. Both ways have a certain appeal, the latter may be a bit more flexible and encapsulated, while the former is easier to understand and follow.

Another thing to note is that we could also leverage the dynamic import() function for retrieving these microfrontends. As such we would still use import maps for the shared dependencies, but we would write

Promise.all([
  'microfrontend1',
  'microfrontend2',
  'microfrontend3'
].map(dir => System.import(`./${dir}/index.js`)));

One of the advantages of this form is that we can now wait for the exports and wire them up explicitly, instead of requiring an implicit way via the defined global function.

Build Process Considerations

Usually, the hard part with microfrontends is not in defining the integration system, but rather how the domain is decomposed (following, e.g., domain-driven design principles) and how the different parts are rolled out. While I’d love to give you some silver-bullet like advise for the former, I’ll take the safe road here with the latter.

We’ve already seen that there are three fundamentally different ways of implementing microfrontends — and that we would like to focus on the third option: runtime focused. Under this assumption we need to be able to define a system that allows microfrontends to be independently created, published, and maintained.

Using Webpack as a bundler we can just do that — using SystemJS, exposing an HMR-ready debug build, and being able to ship independent modules.

The critical piece is the right webpack.config.js.

A simple variant may look like:

const path = require("path");

module.exports = {
  entry: path.resolve(__dirname, "src", "index.js"),
  output: {
    filename: "bundle.js",
    libraryTarget: "system",
    path: path.resolve(__dirname, "dist"),
    jsonpFunction: "webpackJsonp_my_mfe",
  },
  module: {
    rules: [
      {
        parser: {
          system: false,
        },
      },
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },
  devtool: "sourcemap",
  devServer: {
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
    disableHostCheck: true,
  },
  externals: [
    /* place shared dependencies here */
  ],
};

Here we instruct Webpack to create the output for the SystemJS target in the dist folder.

The index.js in the src folder may be as simple as:

import { setPublicPath } from "systemjs-webpack-interop";
import { definition } from "./definition";

setPublicPath("my-mfe");

if (typeof registerMicrofrontend === "function") {
  registerMicrofrontend(definition);
}

The setPublicPath utility sets the special __webpack_public_path__ to the public path of the given SystemJS module. Naturally, this should be the name of the module from the package.json, however, ultimately this depends on the chosen name in the import map. Therefore, its crucial to use the same (i.e., correct or original) name of the package in the import map. In the example above we just named the module my-mfe.

The beauty of this approach is that we can still publish an independent ESM while being able to also start a debugging process using the import-map-overrides package and the webpack-dev-server.

Dynamic Import Maps

Let’s say we are happy with the approach so far and our build system just works. How can the different modules be published independently without requiring a change on the app shell’s served HTML?

Turns out there are multiple options for this one:

  • Regenerate only the JSON file upon build (using, e.g., packmap)
  • Deploy to a service which modifies the JSON file (a ready solution would be import-map-deployer)
  • Use a SaaS solution that exists and exposes an import map (e.g., Piral Cloud)

But even then we still have a problem; the second (i.e., non-shared dependencies) import map is now “externalized” and no longer integrated. How can we find the names of these to perform the import? Here, the lazy loading nature of import maps seems to be against us...

Just to be on the same page: What we want looks like

<!doctype html>
<script type="systemjs-importmap" category="dependencies" src="./dependencies.json"></script>
<script type="systemjs-importmap" category="microfrontends" src="https://feed.piral.cloud/api/v1/importmap/esm-sample"></script>
<script src="./shell.js"></script>

Note: no inline specification and no explicit import.

One easy way here is to just get the URL of the microfrontends import map and retrieve the different microfrontends from there.

function importMicrofrontends(names) {
  return Promise.all(names.map(name => System.import(name)));
}

function loadMicrofrontends(url) {
  return fetch(url)
    .then(res => res.json())
    .then(res => importMicrofrontends(Object.keys(res.imports)));
}

loadMicrofrontends(document.querySelector("script[category=microfrontends").src);

This way we definitely need to cache the import maps on the client, otherwise we would end up with two HTTP requests. Ideally, a single one for new users and none for existing users should be performed.

Quick Demo

A really simple demo may be the famous tractor store from micro-frontends.org.

For this demo we require two pieces of functionality:

  1. A way to register a page
  2. A way to register a component that can be shared

Obviously, proven microfrontend frameworks such as Piral can handle this quite well, but for this demo we want to create everything from scratch.

The final page for this demo should look like the screenshot below:

The look of the demo app — taken from micro-frontends.org

For the app shell we use a simple HTML file. The feed for the microfrontends remains dynamic while the import map exposes the shared dependencies.

<!DOCTYPE html>
<meta charset="UTF-8" />
<title>App Shell for ESM Microfrontends</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link href="./style.css" rel="stylesheet" />
<script
  type="systemjs-importmap"
  category="dependencies"
  src="./dependencies.json"
></script>
<script
  type="systemjs-importmap"
  category="microfrontends"
  src="https://feed.piral.cloud/api/v1/importmap/dynamic-esm-microfrontends-demo"
></script>
<div id="app"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.4.0/system.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.4.0/extras/amd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.4.0/extras/named-exports.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.4.0/extras/named-register.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.4.0/extras/use-default.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/6.4.0/extras/dynamic-import-maps.min.js"></script>
<script>
  Promise.all([System.import("react"), System.import("react-dom")]).then(
    ([React, ReactDom]) => {
      // prepare component system

      // load microfrontends
    }
  );
</script>

While the actual loading of the microfrontends follows the code snippet above, we can get creative in the “prepare component system” section.

One simple example is to declare three globally exposed functions, getComponent, registerComponent, and registerPage. For simplicity, we'll not introduce a router. So the page will always be a single page.

const components = {};
window.getComponent = (name) =>
  components[name] ||
  ((props) => {
    const [component, setComponent] = react.useState(null);
    react.useEffect(() => {
      const handler = (ev) => {
        if (ev.detail === name) {
          setComponent(components[name]);
        }
      };
      window.addEventListener("component-registered", handler);
      return () =>
        window.removeEventListener("component-registered", handler);
    }, []);

    if (typeof component === "function") {
      return react.createElement(component, props);
    }

    return null;
  });

window.registerPage = (component) => {
  reactDom.render(
    react.createElement(component),
    document.querySelector("#app")
  );
};

window.registerComponent = (name, component) => {
  components[name] = component;
  window.dispatchEvent(
    new CustomEvent("component-registered", {
      detail: name,
    })
  );
};

While most parts are quite simple, the getComponent can be tricky. To avoid scenarios where a component is used before its registered, we'll also be able to return a "default component", which listens for changes to the registered components. If a change is detected the component is updated.

For change notifications we’ll use custom events — a DOM standard that can be used without relying on a particular framework.

The whole code for this demo is on GitHub. A link to a live demo is in the README.

Conclusion

Using ES Modules for microfrontends is a great idea. Leveraging tools like Webpack and SystemJS we can utilize up and coming browser standards such as import maps to not only provide support for current browsers, but be ready when all browsers have caught up.

While there are many ways to create outstanding solutions using the microfrontend architecture today, the simplicity and flexibility of ESMs is yet to be beaten. Part of this is the framework and tooling independent basis, which, obviously, comes with a lot of room to be filled with creativity.

Top comments (0)