Dynamic Module Federation with Angular (The Microfrontend Revolution, Part 3)

Loading microfrontends not known at compile time.


This article is part of a series:


 

In the previous article of this series, I've shown how to use Webpack Module Federation for loading separately compiled microfrontends into a shell. As the shell's webpack configuration describes the microfrontends, we already needed to know them when compiling it.

In this article, I'm assuming a more dynamic situation where the shell does not now the microfrontends or even their number upfront. Instead, this information is provided at runtime via a lookup service.

The following image displays this idea:

The shell loads a microfrontend it is informed about on runtime

For all microfrontends the shell gets informed about at runtime it displays a menu item. When clicking it, the microfrontend is loaded and displayed by the shell's router.

As usual, the source code used here can be found in my GitHub account.

Disclaimer: Module Federation is a brand-new technology which comes with webpack 5 (currently in beta). To make it work with Angular already today, I'm using a patched CLI version not intented for production as well as a custom webpack configuration. Once webpack 5 is final and the CLI supports it, this all will not be needed anymore.

Module Federation Config

Let's start with the shell's Module Federation configuration. In this scenario, it's as simple as this:

new ModuleFederationPlugin({
  remotes: {},
  shared: ["@angular/core", "@angular/common", "@angular/router"]
}),

We don't define any remotes (microfrontends) upfront but configure the packages we want to share with the remotes we get informed about at runtime.

The configuration of the microfrontends, however, looks like in the previous article:

new ModuleFederationPlugin({
  name: "mfe1",
  library: { type: "var", name: "mfe1" },
  filename: "remoteEntry.js",
  exposes: {
    Module: './projects/mfe1/src/app/flights/flights.module.ts'
  },
  shared: ["@angular/core", "@angular/common", "@angular/router"]
}),

Loading Microfrontends at Runtime

For loading microfrontends at runtime we need some helper functions dealing with low level aspects.

If you are not interested into these details, you can also skip this section and use the loadRemoteModule function as a black box. Everything you need for this is found in the provided example within the file federation-utils.ts you can copy into your project.

The first helper function we are looking at here is called loadRemoteEntry. It implements a simple script loader for loading the microfrontend's remote entry point:

const moduleMap = {};

function loadRemoteEntry(remoteEntry: string): Promise<void> {
    return new Promise<any>((resolve, reject) => {

        if (moduleMap[remoteEntry]) {
            resolve();
            return;
        }

        const script = document.createElement('script');
        script.src = remoteEntry;

        script.onerror = reject;

        script.onload = () => {
            moduleMap[remoteEntry] = true;
            resolve(); // window is the global namespace
        }

        document.body.append(script);
    });
}

After loading the remote entry point, we can use webpack's runtime API to load the microfrontends.

To understand the needed part of this API we need to know three of the underlying concepts:

  • Scope: Module Federation puts shared libraries into a scope. In order to prevent version conflicts, it allows having different versions of a package within different scopes. E. g. one scope could contain RxJS 6 while another scope provides RxJS 7.

    By using the right scopes, each microfrontend gets the version it needs.
    To make things simple, here we are only using the so called default scope.

  • Container: A remote (microfrontend) is loaded from a container. Such a container allows to share libraries and to retrieve EcmaScript modules exposed by the remote.

  • Factory: When requesting an exposed module, we get a factory function. It returns the module as an object with all exports.

To make working with these concepts easier, I created some typings for it:

type Scope = unknown;
type Factory = () => any;

type Container = {
    init(shareScope: Scope): void;
    get(module: string): Factory;
};

declare const __webpack_init_sharing__: (shareScope: string) => Promise<void>;
declare const __webpack_share_scopes__: { default: Scope };

The Container representing a remote needs to be initialized with a scope for sharing. The init method takes care of this. Besides this, get returns a factory function for an exposed module. Also, the function __webpack_init_sharing__ defines the scope used by the host.

The following function taken from the official module federation examples uses the described API for retrieving an exposed module:

async function lookupExposedModule<T>(remoteName: string, exposedModule: string): Promise<T> {
      // Initializes the share scope. This fills it with known provided modules from this build and all remotes
      await __webpack_init_sharing__("default");
      const container = window[remoteName] as Container; 

      // Initialize the container, it may provide shared modules
      await container.init(__webpack_share_scopes__.default);
      const factory = await container.get(exposedModule);
      const Module = factory();
      return Module as T;
}

Now, let's use both helper functions together to load a remote entry and fetch an exposed module:

export type LoadRemoteModuleOptions = { 
    remoteEntry: string; 
    remoteName: string; 
    exposedModule: string
}

export async function loadRemoteModule(options: LoadRemoteModuleOptions): Promise<any> {
    await loadRemoteEntry(options.remoteEntry);
    return await lookupExposedModule<any>(options.remoteName, options.exposedModule);
}

If we wanted to load a module Module exposed by a remote mfe1 with classic (static) module federation, we would use a dynamic import:

const module = await import('mfe1/Module');

When using the dynamic approach described here for the same task, remoteName points to mfe1 and exposedModule points to Module. Also, remoteEntry informs about the remote entry's URL, e. g. http://localhost:3000/remoteEntry.js.

Routing to Dynamic Microfrontends

Now, we can use our helper function loadRemoteModule to lazy load our separately compiled microfrontends via the router:

const configuredUrl = 'http://localhost:3000/remoteEntry.js';

export const APP_ROUTES: Routes = [
    {
      path: '',
      component: HomeComponent,
      pathMatch: 'full'
    },
    {
      path: 'flights',
      loadChildren: () => loadRemoteModule({
          remoteEntry: configuredUrl,
          remoteName: 'mfe1',
          exposedModule: 'Module'
        })
        .then(m => m.FlightsModule)
    },
    [...]
];

As you might have noticed, we're just switching out the dynamic import normally used here by a call to loadRemoteModule which also works with key data not known at compile time.

Addon: Dynamic Routes for Dynamic Microfrontends

There might be situations where you don't even know the number of microfrontends upfront. Hence, we also need an approach for setting up the routes dynamically.

For this, I've defined a Microfrontend type holding all the key data for the routes:

export type Microfrontend = LoadRemoteModuleOptions & {
    displayName: string;
    routePath: string;
    ngModuleName: string;
}

Besides the properties provided by the above mentioned LoadRemoteModuleOptions, it has some additional ones we need for the dynamic routes and the hyperlinks pointing to them:

  • displayName: Name that should be displayed within the hyperlink leading to route in question.

  • routePath: Path used for the route.

  • ngModuleName: Name of the Angular Module exposed by the remote.

For loading this key data, I'm using a LookupService:

@Injectable({ providedIn: 'root' })
export class LookupService {
    lookup(): Promise<Microfrontend[]> {
        [...]
    }
}

After receiving the Microfrontend array from the LookupService, we can build our dynamic routes:

export function buildRoutes(options: Microfrontend[]): Routes {

    const lazyRoutes: Routes = options.map(o => ({
        path: o.routePath,
        loadChildren: () => loadRemoteModule(o).then(m => m[o.ngModuleName])
    }));

    return [...APP_ROUTES, ...lazyRoutes];
}

This function creates one route per array entry and combines it with the static routes in APP_ROUTES.

Everything is put together in the shell's AppComponent. It's ngOnInit method fetches the key data, builds routes for it, and resets the Router's configuration with them:

@Component({ [...] })
export class AppComponent implements OnInit {

  microfrontends: Microfrontend[] = [];

  constructor(
    private router: Router,
    private lookupService: LookupService) {
  }

  async ngOnInit(): Promise<void> {
    this.microfrontends = await this.lookupService.lookup();
    const routes = buildRoutes(this.microfrontends);
    this.router.resetConfig(routes);
  }
}

Besides this, the AppComponent is also rendering a link for each route:

<li *ngFor="let mfe of microfrontends">
    <a [routerLink]="mfe.routePath"></a>
</li>

Conclusion

Dynamic Module Federation provides more flexibility as it allows loading microfrontends we don't have to know at compile time. We don't even have to know their number upfront. This is possible because of the runtime API provided by webpack.

The used helper functions leveraging the runtime API might seem to be a bit confusing at first sight. However, if we thread them as a black box, the code needed for Dynamic Module Federation looks similar than code normally used for lazy loading.

Don't Miss Anything!


Subscribe to our newsletter to get all the information about Angular.


* By subscribing to our newsletter, you agree with our privacy policy.

Unsere Angular-Schulungen

Aktuelle Blog-Artikel

Nur einen Schritt entfernt!

Stellen Sie noch heute Ihre Anfrage,
wir beraten Sie gerne!

Jetzt anfragen!