Micro Apps with Web Components using Angular Elements

Case study for writing micro apps with web components



Update on 2018-05-04: Updated for @angular/elements in Angular 6
Update on 2018-08-19: Added option to use the CLI for building a self-contained bundle for each micro app

Source code: https://github.com/manfredsteyer/angular-microapp

In one of my last blog posts I've compared several approaches for using Single Page Applications, esp. Angular-based ones, in a microservice-based environment. Some people are calling such SPAs micro frontends; other call them Micro Apps.

As you can read in the mentioned post, there is not the one and only perfect approach but several feasible concepts with different advantages and disadvantages.

In this post I'm looking at one of those approaches in more detail: Using Web Components. For this, I'm leveraging the new Angular Elements library (@angular/elements) which is available beginning with Angular 6. The source code for the case study decribed can be found in my GitHub repo.

Case Study

The case study presented here is as simple as possible. It contains a shell app that dynamically loads and activates micro apps. It also takes care about routing between the apps (meta-routing) and allows them to communicate with each other using message passing. They are just called Client A and Client B. In addition, Client B also contains a widget from Client A.

Client A is activated

Client B with widget from Client A

Project structure

Following the ideas of micro services, each part of the overall solution would be a separate project. This allows different teams do work individually on their parts without the need of much coordination.

To make this case study a bit easier, I've decided to use one CLI project with a sub project for each part. This is something, the CLI supports beginning with version 6.

You can create a sub project using ng generate application my-sub-project within an existing one.

Using this approach, I've created the following structure:

  + projects
    +--- client-a
         +--- src
    +--- client-b
         +--- src
  + src 

The outer src folder at the end is the folder for the shell application.

Micro Apps as Web Components with Angular Elements

To allow loading the micro apps on demand into the shell, they are exposed as Web Components using Angular Elements. In addition to that, I'm providing further Web Components for stuff I want to share with other Micro Apps.

Using the API of Angular Elements isn't difficult. After npm installing @angular/elements you just need to declare your Angular Component with a module and put it also into the entryComponents array. Using entryComponents is necessary because Angular Elements are created dynamically at runtime. Otherwise the compiler would not know about them.

Than you have to create a wrapper for your component using createCustomElement and register it as a custom element with the browser using its customElements.define method:

import { createCustomElement } from '@angular/elements'; [...] @NgModule({ [...] bootstrap: [], entryComponents: [ AppComponent, ClientAWidgetComponent ] }) export class AppModule { constructor(private injector: Injector) { } ngDoBootstrap() { const appElement = createCustomElement(AppComponent, { injector: this.injector}) customElements.define('client-a', appElement); const widgetElement = createCustomElement(ClientAWidgetComponent, { injector: this.injector}) customElements.define('client-a-widget', widgetElement); } }

The AppModule above only offers two custom elements. The first one is the root component of the micro app and the second one is a component it shares whit other micro apps. Please note that it does not bootstrap a traditional Angular component. Hence, the bootstrap array is empty and we need to introduce an ngDoBootstrap method intended for manual bootstrapping.

If we had traditional Angular components, services, modules, etc., we could also place this code inside of them.

After this, we can use our Angular Components like ordinary HTML elements:

<client-a [state]="someState" (message)="handleMessage($event)"><client-a>

While the last example uses Angular to call the Web Component, this also works with other frameworks and VanillaJS. In this case, we have to use the respective syntax of the hosting solution when calling the component.

When we load web components in an other Angular application, we need to register the CUSTOM_ELEMENTS_SCHEMA:

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; [...] @NgModule({ declarations: [AppComponent ], imports: [BrowserModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], providers: [], bootstrap: [AppComponent] }) export class AppModule { }

This is necessary to tell the Angular compiler that there will be components it is not aware of. Those components are the web components that are directly executed by the browser.

We also need a polyfill for browsers that don't support Web Components. Hence, I've npm installed @webcomponents/custom-elements and referenced it at the end of the polyfills.ts file:

import '@webcomponents/custom-elements/custom-elements.min';

This polyfill even works with IE 11.

Routing across Micro Apps

One thing that is rather unusual here, is that whole clients are implemented as Web Components and hence they are using routing:

@NgModule({ imports: [ ReactiveFormsModule, BrowserModule, RouterModule.forRoot([ { path: 'client-a/page1', component: Page1Component }, { path: 'client-a/page2', component: Page2Component }, { path: '**', component: EmptyComponent} ], { useHash: true }) ], [...] }) export class AppModule { [...] }

An interesting thing about this simple routing configuration is that it uses the prefix client-a for all but one route. The last route is a catch all route displaying an empty component. This makes the application disappear, when the current path does not start with its prefix. Using this simple trick you can allow the shell to switch between apps very easily.

Please note that I'm using hash based routing as after changing the hash all routers in our micro apps will update their route. Unfortunately, this isn't the case with the default location strategy which leverages the push API.

When bootstrapping such components as Web Components we have to initialize the router manually:

@Component([...]) export class ClientAComponent { constructor(private router: Router) { router.initialNavigation(); // Manually triggering initial navigation } }

Build Process - Option 1: Angular CLI

To get one self-contained bundle for our Web Component, we can use the CLI. Unfortunately, it always creates several bundles by default. To change this behavior, I'm using my CLI-extension called ngx-build-plus:

npm i ngx-build-plus --save-dev

It contains builders that tell the CLI which build steps to perform. To register the builder for getting a self-contained bundle, modify your angular.json:

[...] "architect": { "build": { "builder": "ngx-build-plus:build", [...] } } [...]

In addition, I'm using the following npm scripts for building the shell as well as the micro apps:

"build": "npm run build:shell && npm run build:clients", "build:clients": "npm run build:client-a && npm run build:client-b", "build:client-a": "ng build --prod --project client-a --single-bundle true --output-hashing none --vendor-chunk false --output-path dist/shell/client-a", "build:client-b": "ng build --prod --project client-b --single-bundle true --output-hashing none --vendor-chunk false --output-path dist/shell/client-b",
"build:shell": "ng build --project shell",

Please note that the output-path switch puts the micro apps client-a and client-b into a subfolder of the shell. Hence, the shell can fetch their bundles via a relative path. While this is not necessary, it makes testing easier.

In an similar example that can be found here, I'm copying the Micro App's bundles over to the shell's assets folder using the node package cpr. This makes debugging even easier.

Build Process - Option 2: Webpack

As an alternative, I'm using a modified version of the webpack configuration from Vincent Ogloblinsky's blog post.

const AotPlugin = require('@ngtools/webpack').AngularCompilerPlugin; const path = require('path'); const PurifyPlugin = require('@angular-devkit/build-optimizer').PurifyPlugin; const webpack = require('webpack'); const clientA = { entry: './projects/client-a/src/main.ts', resolve: { mainFields: ['browser', 'module', 'main'] }, module: { rules: [ { test: /\.ts$/, loaders: ['@ngtools/webpack'] }, { test: /\.html$/, loader: 'html-loader', options: { minimize: true } }, { test: /\.js$/, loader: '@angular-devkit/build-optimizer/webpack-loader', options: { sourceMap: false } } ] }, plugins: [ new AotPlugin({ skipCodeGeneration: false, tsConfigPath: './projects/client-a/tsconfig.app.json', hostReplacementPaths: { "./src/environments/environment.ts": "./src/environments/environment.prod.ts" }, entryModule: path.resolve(__dirname, './projects/client-a/src/app/app.module#AppModule' ) }), new PurifyPlugin() ], output: { path: __dirname + '/dist/shell/client-a', filename: 'main.bundle.js' }, mode: 'production' }; const clientB = { [...] }; module.exports = [clientA, clientB];

In addition to that, I'm using some npm scripts to trigger both, the build of the shell as well as the build of the micro apps. For this, I'm copying the bundles for the micro apps over to the shell's dist folder. This makes testing a bit easier:

"scripts": { "start": "live-server dist/shell", "build": "npm run build:shell && npm run build:clients ", "build:clients": "webpack", "build:shell": "ng build --project shell", [...] }

Loading bundles

After creating the bundles, we can load them into a shell application. A first simple approach could look like this:

<client-a></client-a> <client-b></client-b> <script src="client-a/main.bundle.js"></script> <script src="client-b/main.bundle.js"></script>

This example shows one more time that a web component works just as an ordinary html element.

We can also dynamically load the bundles on demand with some lines of simple DOM code. I will present a solution for this a bit later.

Communication between Micro Apps

Even though micro apps should be as isolated as possible, sometimes we need to share some information. The good message here is that we can leverage attributes and events for this:

To implement this idea, our micro apps get a property state the shell can use to send down some application wide state. It also gets an event message to notify the shell:

@Component({ ... }) export class AppComponent implements OnInit { @Input('state') set state(state: string) { console.debug('client-a received state', state); } @Output() message = new EventEmitter<any>(); [...] }

The shell can now bind to these to communicate with the Micro App:

<client-a [state]="appState" (message)="handleMessage($event)"></client-a> <client-b [state]="appState" (message)="handleMessage($event)"></client-b>

Using this approach one can easily broadcast messages down by updating the appState. And if handleMessage also updates the appState, the micro apps can communicate with each other.

One thing I want to point out is that this kind of message passing allows inter app communication without coupling them in a strong way.

Dynamically Loading Micro Apps

As web components work as traditional html elements, we can dynamically load them into our app using DOM. For this task, I've created a simple configuration object pointing to all the data we need:

config = { "client-a": { path: 'client-a/main.bundle.js', element: 'client-a' }, "client-b": { path: 'client-b/main.bundle.js', element: 'client-b' } };

To load one of those clients, we just need to create a script tag pointing to its bundle and an element representing the micro app:

load(name: string): void { const configItem = this.config[name]; const content = document.getElementById('content'); const script = document.createElement('script'); script.src = configItem.path; script.onerror = () => console.error(error loading <span class="hljs-subst">${configItem.path}</span>); content.appendChild(script); const element: HTMLElement = document.createElement(configItem.element); element.addEventListener('message', msg => this.handleMessage(msg)); content.appendChild(element); element.setAttribute('state', 'init'); } handleMessage(msg): void { console.debug('shell received message: ', msg.detail); }

By hooking up an event listener for the message event, the shell can receive information from the micro apps. To send some data down, this example uses setAttribute.

We can even decide when to call the load function for our application. This means, we can implements eager loading or lazy loading. For the sake of simplicity I've decided for the first option:

ngOnInit() { this.load('client-a'); this.load('client-b'); }

Using Widgets from other Micro Apps

Using widgets from other Micro Apps is also a piece of cake: Just create an html element. Hence, all client b has to do to use client a's widget is this:

<client-a-widget></client-a-widget>

Evaluation

Advantages

  • Styling is isolated from other Microservice Clients due to Shadow DOM or the Shadow DOM Emulation provided by Angular out of the box.
  • Allows for separate development and separate deployment
  • Mixing widgets from different Microservice Clients is possible
  • The shell can be a Single Page Application too
  • We can use different SPA frameworks in different versions for our Microservice Clients

Disadvantages

  • Microservice Clients are not completely isolated as it would be the case when using hyperlinks or iframes instead. This means that they could influence each other in an unplanned way. This also means that there can be conflicts when using different frameworks in different versions.
  • We need polyfills for some browsers
  • We cannot leverage the CLI for generating a self-contained package for every client. Hence, I used webpack.

Tradeoff

  • We have to decide, whether we want to import all the libraries once or once for each client. This is more or less a matter of bundling. The first option allows to optimize for bundle sizes; the last option provides more isolation and hence separate development and deployment. This properties are considered valuable architectural goals in the world of micro services.