Angular/Angular micro frontend Tutorial(Adventures in Micro Frontend Part 2)

Rohit Saxena
10 min readMar 15, 2020

--

Part 2!

This is part of my series of Adventures with Micro Frontend. This is the second part.

Series:
1. Theory — Why micro frontends
2. Example (implementation) — angular parent, angular child
3. Adding React micro frontend
4. Deploying to AWS

Repo for Parent: https://github.com/rohitmsaxena/microfrontend-parent-demo

Repo for Child: https://github.com/rohitmsaxena/microfrontend-child-demo

I am going to show how to create a parent and child angular app where the child app will be a web component that the parent application pulls in.

Here are the key things we need to be able to:

  • Build micro frontend to run locally (outside of parent application)
  • Bundle micro frontend application as one package that can be deployed to CDN (for our example we are going to just deploy it on localhost:3000)
  • Export component as web component
  • Import micro frontend to parent application (lazily)
  • Pass data into and out of the micro frontend.
We are going to bundle up my micro frontend repo to be one main.js bundle that we can pull into the parent application.

Additionally, our micro frontend repo DOESN’T have to contain just one exportable web component!

Each web component can exist in its own module and then we can export out that module’s component.

Okay, lets get started!

Phase 0: Install lite-server

In order to run both the micro frontend and the parent application, we are going to need lite server to run the micro frontend.

npm install -g lite-server

Phase 1: Create micro frontend app

Step 1: Create new app

Creates a empty project with scss style instead of css.

ng n child --style scss

Step 2: Create sample component

We are going to create an empty sample component. Note: I would normally build this with the style and template are separate files. However, for demonstration purposes it is easier to show.

ng g m sample
ng g c sample --inlineStyle true --inlineTemplate true

We are also going to create a simple box with a input and text field. Here is the code. We are later going to figure out how to pass data into this component from the parent application and send data back using output.

// sample.component.ts
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';

@Component({
selector: 'app-sample',
template: `
<div style="border: blue solid 1px">
<p>Data from Parent: {{dataFromParent}}</p>
<input [(ngModel)]="input" [value]="input" (keydown.enter)="send()">
</div>
`,
styles: []
})
export class SampleComponent {
@Input() dataFromParent: string;
@Output() emitDataToParent = new EventEmitter<string>();
input: string;
constructor() { }

send() {
this.emitDataToParent.emit(this.input)
this.input = '';
}
}

To be able to test out the sample component locally, we are going to replace the app.component.html file with the following:

<app-sample></app-sample>

Also import the module into app.module.ts

// app.module.ts
...
import {SampleModule} from './sample/sample.module';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
SampleModule
],
bootstrap: [AppComponent]
})
export class AppModule {
}

You can run it locally by ng serve to see sample works!

Step 3: Export sample component

Add the following code to app.module.ts: (explanation follows)

// app.module.ts
import {ApplicationRef, DoBootstrap, Injector, NgModule} from '@angular/core';
import { CommonModule } from '@angular/common';
import {createCustomElement} from '@angular/elements';
import {SampleComponent} from './sample/sample.component';
import {SampleModule} from './sample/sample.module';
import {BrowserModule} from '@angular/platform-browser';

const local = true;
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
SampleModule
],
providers: [],
entryComponents: [SampleComponent],
bootstrap: [local ? AppComponent : []]
})
export class AppModule implements DoBootstrap{
constructor(private injector: Injector) {
const micro = createCustomElement(SampleComponent, {injector: this.injector})
customElements.define('micro-app', micro);
}

ngDoBootstrap(appRef: ApplicationRef): void {}
}
  • The entryComponet defines the component that is ultimately going be exported. Note: If the sample component had other components nested inside, it doesn’t need to be added to entryComponents. Just the main component that we are going to turn into a web component.
  • We need to import BrowserModule and the SampleModule .
  • Implementing DoBootstrap gives us ngDoBootstrap() lifecycle hook to let us manually bootstrap the individual webcomponents.
  • customElements.define() is how we wrap the angular component into a web component. The first parameter is the html tag that our web component will be inside. IMPORTANT: Custom HTML tags must have a dash in them. Link
  • The bootstrap section allows us to create a conditional bootstrap based on which mode we want to run our application. When we are testing our micro frontend locally, we want to use bootstrap AppComponent so we test out our various components inside it.
    However, when we want to mount it to be run inside a parent app, we don’t want to bootstrap AppComponent .

Step 4: Set up build process

When we export the child components, we want to use a unique build process that will ensure the entire project builds into one main.js file. This is done by installing an alternative build process:

ng add ngx-build-plus

If you watch the angular.json file before & after running this step you can see how the projects.architect.build.builder will change. However, we need to modify "builder": "ngx-build-plus:build" . Here is what the file looks like:

{...,
"projects": {
"child": {...,
"architect": {
"build": {
"builder": "ngx-build-plus:build",
...}

Next, we need to modify our package.json file a little to build it as one bundled .js file:

...
"scripts": {
...,
"build": "ng build --prod --single-bundle --output-hashing none --vendor-chunk false",
"buildrun": "npm run-script build && lite-server --baseDir=dist/child ",
...
},...

And done! 😃

So, now:

  1. When we want to test locally (outside the parent application), we can render the micro frontend by calling its angular selector tag in app.component.html .
    We just set the local flag to true in app.module.ts and run it using npm start or ng serve.
  2. We can also build the application as one main.js that our parent application will use by running npm build NOTE: Remember to let local=false when building as a micro frontend.
    The files in the dist/child folder can be hosted on any server and be ready to be used in a parent application.
  3. We can build and host locally using npm buildrun which uses the lite-server to host the files to http://localhost:3000

Later I am going to also cover how to use AWS codepipeline to build this into a S3 bucket.

Phase 2: Build parent application

So now that we have a web component exported, we need to build a parent application that can actually import this!

Our objectives are:

  • Import micro component into a wrapper angular application that we can then use anywhere we want.
  • Lazy load the component using LazyElementModule
  • Pass information to and from component using @Input() and @Output()
  • So next, we are going to create a parent repo that is going to have a wrapper component that the web component is going to live in. We are also going to lazily load it for convenience.

Step 0: Create parent app

ng n parent --style scss

Step 1: Install angular elements and wrap component (API layer)

To add the ability to use custom elements to the parent we add the following:

ng add @angular/elements

By default angular will stop the build process and error out whenever it doesn’t recognize a html element tag. However, with micro frontend we need to let angular know that sometimes we need to make an exception.

We can using schemas: [CUSTOM_ELEMENTS_SCHEMA] in a module to let angular know that inside that module, there might be html tags that it doesn’t recognize. However, in that module, we are going to lose Angular’s cautionary practice of warning us whenever it sees a unrecognized html tag. That is why we want to only use it in the wrapper module for the micro frontend. We are also going to use the wrapper to define exactly what the Input() and Output() tags are for the component. Using this wrapper component rather then directly calling the micro frontend will give us the same type safety that we get when we use angular components natively.

cd src/app
mkdir webcomponents
cd webcomponents
ng g m childWrapper
ng g c childWrapper --inlineStyle true --inlineTemplate true

For some reason when I created the component it got added to the app.module.ts . Remove it from the declaration in the app.module.ts and instead add it to your child-wrapper.component.ts

This is what the module looks like:

import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from '@angular/core';
import { CommonModule } from '@angular/common';
import {ChildWrapperComponent} from './child-wrapper.component';

@NgModule({
declarations: [ChildWrapperComponent],
imports: [
CommonModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
exports: [ChildWrapperComponent]
})
export class ChildWrapperModule { }

Note the CUSTOM_ELEMENTS_SCHEMA declaration.

However, before we pull in the micro frontend, we do need to add lazy loading to the project.

Step 2: Add lazy loading

While lazy loading isn’t strictly needed to get our micro frontend working, it helps greatly improve our application’s performance and modularity. Without it, we could load all our microfrontends inside the index.html file but this will allow us to do so more dynamically.

We are going to use @angular-extensions/elements (link). Before we install it, we do need to find out what major version of @angular/core our parent application is using. Check your package.json file to check.

package.json in parent app.

We have to match the major version of the package to the major version of the @angular-extensions/elements package. So based on this, I need to use the 8.x.x version of the package. Check the current tags to get what that means. For this demo it means 8.12.0.

npm i @angular-extensions/elements@^8.12.0 --save

Next, in our app.module.ts we need to import LazyElementsModule

// app.module.ts
import ...
@NgModule({
...,
imports: [
BrowserModule,
LazyElementsModule
],...
})
export class AppModule { }

Next, we also add that to the wrapper component we created previously by adding the same import to child-wrapper.module.ts

// child-wrapper.module.ts
import ...

@NgModule({
declarations: [ChildWrapperComponent],
imports: [
CommonModule,
LazyElementsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
exports: [ChildWrapperComponent]
})
export class ChildWrapperModule { }

Step 3: Finish API layer

Now, we want to define in the html the micro frontend by the HTML Element tag we defined and declare the @Input() and @Output() .

import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core';

@Component({
selector: 'app-child-wrapper',
template: `
<micro-app *axLazyElement="link" (emitDataToParent)="dataToParent($event)" [dataFromParent]="dataToChild"></micro-app>
`,
styles: []
})
export class ChildWrapperComponent {
link: string = 'http://localhost:3000/main.js'
@Input() dataToChild: string;
@Output() dataFromChild = new EventEmitter<string>()
listOfData: string[] = [];

dataToParent($event: CustomEvent) {
this.dataFromChild.emit($event.detail);
}
}
  • The template section shows you how we define the custom element.
  • The link is the url to the hosted microfrontend’s main.js file. Ideally this would be stored in some sort of config.json file instead of inside this component definition.
  • The output binding returns a CustomEvent and we get the detail from that to access the actual information that was sent to parent.

Lastly, we need to add zone.js and custom-elements-e5-adapter.js to our index.html

\\ index.html
<!doctype html>
<html lang="en">
<head> ... </head>
<body> ... </body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/2.2.10/custom-elements-es5-adapter.js"></script>
</html>

And done! Now when we have defined our micro frontend wrapper/API in our parent app. We can use this component wherever we want in our parent app.

Step 4: Use wrapper

We can use this component wherever we want in the parent application. For this example, we are going to use it in the app.component.ts and app.component.html files.

// app.component.html
<h1>Parent</h1>
<app-child-wrapper [dataToChild]="data" (dataFromChild)="getDataBack($event)"></app-child-wrapper>

And…

///app.component.ts
import { Component } from '@angular/core';

@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'parent';
data: any = 'something from parent!';

getDataBack($event: string) {
console.log($event)
}
}

And there you go! You can now send data to parent by changing the data value. When you render the application you should see that in child. And whenever you put in some information in the input box in the child and press enter, it should log it out from the parent.

One last twist…

So, there is one unique quirk with web components we need to address in order to finish creating our micro frontend.

Normal behavior:

When we are working in one monolithic angular project, whenever we render a component with some input being passed into it, angular is smart enough to wait for the input values to be filled up before trying to render the component. So, we are able to use OnInit() to do whatever data manipulation we want with the inputs.

Problem:

When we define a micro frontend in our parent angular project, we let angular know that we are doing something that angular isn’t expecting. Angular has no idea what the web component looks like and is simply blindly rendering and relying on you to know better.
This means, Angular isn’t able to wait till the Inputs are loaded before it renders the component. So, we can’t use OnInit and instead need to use OnChange to wait for all the inputs to be present in the micro frontend.

So, this is the workaround I found.

\\ sample.component.ts in Micro Frontendimport {Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges} from '@angular/core';

@Component({
selector: 'app-sample',
template: `
<div style="border: blue solid 1px">
<p>Data from Parent: {{dataFromParent}}</p>
<input [(ngModel)]="input" [value]="input" (keydown.enter)="send()">
</div>
`,
styles: []
})
export class SampleComponent implements OnChanges, OnInit{
@Input() dataFromParent: string;
@Output() emitDataToParent = new EventEmitter<string>();
input: string;
ifLoaded: boolean = false;
constructor() { }

send() {
this.emitDataToParent.emit(this.input)
this.input = '';
}

ngOnInit(): void {
if (this.ifLoaded) {
// this code is only going to be run once
console.log(this.dataFromParent);
}
}

ngOnChanges(changes: SimpleChanges): void {
if (!this.ifLoaded) {
this.ifLoaded = true;
this.ngOnInit();
}
// any code that needs to be run every time a change is made
}
}
  • ngOnChanges() will run when the Inputs are updated. At that point, we set the ifLoaded to true and then re-run ngOnInit() .
  • Without the boolean, every time the input value changes, ngOnChanges() will run. For this use case we are just try to find a way to run ngOnInt() which is only run ONCE.
  • For any code that needs to run every time the input value changes, you can add that to ngOnChanges() outside the if conditional.

In the future, there might be better workarounds for this. If anyone reading this finds one, please post it in the comments so I can update this!

--

--