Micro Apps with Web Components using Angular Elements

Update on 2018-05-04: Updated for @angular/elements in Angular 6
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

For building the web components, 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 ${configItem.path}`); 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.

 

 
Hier können Sie eine Anfrage für eine unverbindliche Schulung ode Beratung bzw. einen Workshop erstellen.
 
Unverbindliche Anfrage
 
 

Schulungen

Angular Schulung: Strukturierte Einführung

In dieser Schulung erfahren Sie von bekannten Insidern und Angular Experten der ersten Stunde anhand eines durchgängigen Beispiels, welche Konzepte hinter dem modernen Single-Page-Application-Framework aus der Feder von Google stecken und lernen diese für Ihre eigenen Projekte zu nutzen. Zusätzlich werden sie selbst eine erste Angular-Anwendung zu schreiben. Diese orientiert sich an Best Practices und kann somit als Vorlage für eigene Projekte herangezogen werden. Zum Einsatz kommt die jeweils neueste Version von Angular.

Details

Advanced Angular Schulung: Enterprise-Anwendungen und Architektur

In dieser weiterführenden Intensiv-Schulung lernen Sie von namhaften Insidern, wie sich große und skalierbare Geschäftsanwendungen mit Angular entwickeln lassen. Mehrere Architekturansätze und Best Practices werden anhand einer Fallstudie aufgezeigt und diskutiert. Die Fallstudie wird in den einzelnen Übungseinheiten erweitert und kann als Vorlage für eigene Vorhaben dienen.

Details

Migration von AngularJS 1.x auf Angular (2+)

Seit der Ankündigung von Angular (2+) fragen sich Entwicklungs-Teams, welche Migrationspfade für AngularJS-1.x-Anwendungen vorliegen werden. Das im Lieferumfang von Angular enthaltene ngUpgrade bietet eine Antwort darauf. Es erlaubt einen Parallelbetrieb von AngularJS 1.x und Angular (2+) und stellt somit die Grundlage für eine schrittweise Migration dar.

Details

Progressive Web-Apps mit Angular

Progressive Web Apps bieten den Komfort nativer Anwendungen, indem sie auf moderne Browser APIs, wie Service Worker, setzen. Sie sind installierbar sowie offlinefähig und nutzen Hintergrundprozesse für Datensynchronisation und Push-Notifications. Diese Schulung zeigt anhand eines durchgehenden Beispiels was sich genau hinter diesem neuen Konzept verbirgt, wie solche Anwendungen mit Angular entwickelt werden und wie Sie in Ihren Projekten von den dahinterstehenden Ideen profitieren.

Details

Reaktive Architekturen mit Angular und Redux

Dieses interaktive Seminar vermittelt, wie Sie reaktive Anwendungen mit Angular entwickeln können.

Details

TypeScript

TypeScript gibt Ihnen alle Möglichkeiten der neuen JavaScript-Standards und zusätzlich ein statisches Typsystem, das dabei hilft, Fehler möglichst früh zu erkennen. Außerdem ist TypeScript die Grundlage für Angular. In diesem interaktiven Seminar lernen Sie diese mächtige Sprache anhand einer Fallstudie kennen.

Details

Weitere Schulungen ...